Initial commit
|
|
@ -1,35 +1,44 @@
|
|||
# ---> Android
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.keystore
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
107
README.md
|
|
@ -1,3 +1,106 @@
|
|||
# nextgenmobile
|
||||
# Atomic Habits AI - Habit Tracker & Personal Library
|
||||
|
||||
This is the starting point for Mobile App Development for NextGen
|
||||
Atomic Habits AI is a cross-platform mobile and web application designed to help users build lasting habits through a science-backed tracking system and a personalized AI-powered library. It combines local-first performance with cloud synchronization and AI insights.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
The application follows a **Local-First, Cloud-Sync** architecture.
|
||||
|
||||
### Frontend
|
||||
- **Framework:** [Expo](https://expo.dev/) (React Native) with [Expo Router](https://docs.expo.dev/router/introduction/) for file-based routing.
|
||||
- **Language:** TypeScript.
|
||||
- **State Management:** Local SQLite database for offline-first capabilities, synced with Supabase.
|
||||
- **UI/UX:** Custom design system built with `Vanilla CSS` (via React Native Stylesheets), utilizing `Lucide React Native` for iconography and `Reanimated` for smooth transitions.
|
||||
|
||||
### Backend & Cloud
|
||||
- **Platform:** [Supabase](https://supabase.com/)
|
||||
- **Authentication:** Supabase Auth (Email/Password, JWT).
|
||||
- **Database:** PostgreSQL (Cloud) + SQLite (Local).
|
||||
- **Storage:** Supabase Storage for PDF/Epub books.
|
||||
- **Edge Functions:** Supabase Edge Functions (Deno) for AI processing and book parsing.
|
||||
|
||||
### AI Integration
|
||||
- **Models:** Gemini (via Supabase Edge Functions).
|
||||
- **Features:** Automated book metadata extraction, AI-generated reading insights/synthesis, and interactive habit coaching.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
Security is integrated at every layer of the stack:
|
||||
|
||||
1. **Authentication:** Secure session management using `AsyncStorage` and Supabase JWTs.
|
||||
2. **Row Level Security (RLS):** Supabase RLS policies ensure users can only access their own habits, books, and logs.
|
||||
3. **Secure Storage:** sensitive files are accessed via **Signed URLs** with limited expiration windows (1 hour) rather than public links.
|
||||
4. **Local Encryption:** (Optional/Roadmap) Local SQLite data protection.
|
||||
5. **Environment Variables:** All sensitive keys (Supabase URL, Anon Key) are managed via `.env` files and `EXPO_PUBLIC_` prefixes.
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Database Schema
|
||||
|
||||
The system uses a mirrored schema between local SQLite and cloud PostgreSQL.
|
||||
|
||||
### Core Tables:
|
||||
- **`books`**: Stores library items (`id`, `user_id`, `title`, `author`, `file_uri`, `current_page`, `status`).
|
||||
- **`habits`**: Core habit tracking logic (`id`, `user_id`, `name`, `frequency`, `streak`, `last_completed`).
|
||||
- **`reading_logs`**: Tracks progress over time (`id`, `book_id`, `duration_seconds`, `pages_read`).
|
||||
- **`chat_history`**: Persists AI interactions for context-aware coaching.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Running the Project
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18+)
|
||||
- Expo Go app (for physical device testing) or Android Studio / Xcode (for emulators).
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo-url>
|
||||
cd atomichabitsai
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start the Expo development server
|
||||
npx expo start
|
||||
```
|
||||
|
||||
- Press **`a`** for Android emulator.
|
||||
- Press **`i`** for iOS simulator.
|
||||
- Press **`w`** for web.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 Debugging Details
|
||||
|
||||
### Logging
|
||||
The application uses structured logs for critical paths:
|
||||
- **`[PdfReader]`**: WebView lifecycle and file writing logs.
|
||||
- **`[downloadBook]`**: Sync and storage fetch status.
|
||||
- **`[resolveFileUri]`**: Path resolution debugging for iOS container changes.
|
||||
|
||||
### Common Troubleshooting
|
||||
1. **Stale iOS Paths:** If files don't open on iOS after an update, the `resolveFileUri` utility automatically corrects absolute paths that contain outdated container UUIDs.
|
||||
2. **Sync Failures:** Check the `performMutation` calls in `sync.ts`. Ensure the device has internet access for Supabase connectivity.
|
||||
3. **PDF Reader Crashes:** Ensure the library `@bildau/rn-pdf-reader` is correctly patched via `patch-package` to handle the Legacy FileSystem API in Expo 54.
|
||||
|
||||
---
|
||||
|
||||
## 🧑💻 Backend Developer Notes
|
||||
|
||||
### Edge Functions
|
||||
Located in `/supabase/functions/`:
|
||||
- **`process-book-ai`**: Parses uploaded filenames to suggest titles/authors and generates reading summaries.
|
||||
- **`chat-ai`**: The core engine for the AI habit coach.
|
||||
|
||||
### Storage
|
||||
- Bucket name: `books`
|
||||
- Access: Private (accessed via `createSignedUrl` in `src/lib/file-utils.ts`).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "RCS BatsirAI",
|
||||
"slug": "atomichabitsai",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "atomichabitsai",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.phaseofficial.atomichabitsai",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.phaseofficial.atomichabitsai"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
"origin": "https://atomichabits.ai/",
|
||||
"root": "src/app"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/favicon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": "https://atomichabits.ai/",
|
||||
"root": "src/app"
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "5f2929a1-ae47-4616-a893-8c7161eaafe9"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,230 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path | AI Assistant</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Plus_Jakarta_Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-secondary-container": "#40536d",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-container": "#eaefee",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-variant": "#dde4e3",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"tertiary": "#655b6f",
|
||||
"error": "#a83836",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"on-background": "#2d3433",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary": "#4e607b",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-error-container": "#6e0a12",
|
||||
"outline": "#757c7b",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-surface-variant": "#596060",
|
||||
"primary": "#4a655a",
|
||||
"on-error": "#fff7f6",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"outline-variant": "#acb3b2",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"primary-dim": "#3e594e",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-primary": "#e5fff2",
|
||||
"background": "#f8faf9",
|
||||
"primary-fixed": "#cbe9db"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.glass-panel {
|
||||
background: rgba(248, 250, 249, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface font-body text-on-surface selection:bg-primary-container selection:text-on-primary-container">
|
||||
<!-- Top Navigation -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 flex justify-between items-center w-full px-6 py-4 docked full-width top-0 sticky z-50 no-line-rule bg-[#f1f4f3] dark:bg-slate-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-highest">
|
||||
<img alt="user profile picture" class="w-full h-full object-cover" data-alt="close-up portrait of a woman with a serene expression in natural morning light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB0QWWVOvRdrj_Vfv5qQoDF-mEaziKJXhCbqzC06NAQq0cpzmhkRJn1Ed4n_T_3QWCAnmigxZO4JzN9jrfrm_XSVAxXYyTUJezo-_y3z9mYRoNr0kJ4y08raLXinTZHGYzgUYq2SxUhQWNiZBz56x7EPeLpl-wUywiTEoLA0UjZgzjuFd8HLfyOjrsXS9lxWEwusrtPSZxIIH7O_sK1mC9COn1Gz_Ha-evCwLjMsr32-bYQxsGuERtyJyPuTEXAqvxDZLBQazO6yAWp"/>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-[#4a655a] dark:text-[#cbe9db] font-['Manrope'] tracking-tight">Serene Path</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="text-[#acb3b2] dark:text-slate-500 hover:opacity-80 transition-opacity duration-300">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-4xl mx-auto px-6 pt-8 pb-48 min-h-screen">
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="inline-flex items-center gap-3 px-4 py-2 bg-surface-container-low rounded-full">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</div>
|
||||
<span class="font-label text-label-sm text-primary font-medium tracking-wide">AI is syncing your plan...</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chat History -->
|
||||
<div class="space-y-8">
|
||||
<!-- AI Message -->
|
||||
<div class="flex flex-col items-start gap-3 max-w-[85%]">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<span class="font-label text-label-sm font-bold text-primary uppercase tracking-widest">Serene AI</span>
|
||||
<span class="font-label text-[10px] text-outline-variant">10:02 AM</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl rounded-tl-none">
|
||||
<p class="text-body-lg text-on-surface leading-relaxed">
|
||||
Good morning. I've analyzed your sleep data and upcoming tasks. You seem to have a productive window between 10 AM and 12 PM. Would you like me to block this time for your deep work session?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Message -->
|
||||
<div class="flex flex-col items-end gap-3 max-w-[85%] ml-auto">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<span class="font-label text-[10px] text-outline-variant">10:05 AM</span>
|
||||
<span class="font-label text-label-sm font-bold text-secondary uppercase tracking-widest">You</span>
|
||||
</div>
|
||||
<div class="bg-primary text-on-primary p-6 rounded-2xl rounded-tr-none shadow-sm">
|
||||
<p class="text-body-lg leading-relaxed">
|
||||
Yes, please. Also, can you check if I have any reading assignments for tonight's book club?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI Message -->
|
||||
<div class="flex flex-col items-start gap-3 max-w-[85%]">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<span class="font-label text-label-sm font-bold text-primary uppercase tracking-widest">Serene AI</span>
|
||||
<span class="font-label text-[10px] text-outline-variant">10:05 AM</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl rounded-tl-none">
|
||||
<p class="text-body-lg text-on-surface leading-relaxed mb-4">
|
||||
Of course. I've scheduled your deep work block. Regarding your book club: you have to finish Chapter 4 of "The Art of Stillness".
|
||||
</p>
|
||||
<div class="bg-surface-container-highest/50 p-4 rounded-xl flex items-center gap-4">
|
||||
<div class="w-12 h-16 bg-surface-container rounded shadow-sm overflow-hidden flex-shrink-0">
|
||||
<img alt="book cover" class="w-full h-full object-cover" data-alt="minimalist book cover with abstract blue shapes on a cream background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBcWYI5cWHVTopKWhKJ7aSqKjnVBMPazwJ3XxcalI1000uiyX91i6Y42-JlBlkvq-VMh6SetBPeJMBGepDkoEqF-81CffQd3gkgsv5PCP7tEFoNL2HPvgr58fPUQW5BP7qNnfkgLi8cqJ9tnfGQAtqeipABtPM4Tqx8kIFlVYKxtH5rM5kwhxc7Y3qo0YjpET_9q2aAQAeww_gNQHC4Xyk_SfRVOkwZIb-6ktMF-mwjiCa79fvimJGzgi90EULOoYwahb3-dje6BtEP"/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-headline font-bold text-sm">Chapter 4: The Inner Journey</p>
|
||||
<p class="font-label text-xs text-on-surface-variant">12 pages remaining • 15 min read</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty space for layout rhythm -->
|
||||
<div class="h-12"></div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Bottom Input & Controls Container -->
|
||||
<div class="fixed bottom-0 left-0 w-full z-40 bg-surface/80 backdrop-blur-xl">
|
||||
<div class="max-w-4xl mx-auto px-6 py-6">
|
||||
<!-- Suggested Prompts -->
|
||||
<div class="flex gap-3 mb-6 overflow-x-auto no-scrollbar pb-2">
|
||||
<button class="whitespace-nowrap px-5 py-2.5 bg-surface-container-high rounded-full font-label text-sm font-semibold text-on-surface-variant hover:bg-primary-container hover:text-on-primary-container transition-all duration-300">
|
||||
Update my schedule
|
||||
</button>
|
||||
<button class="whitespace-nowrap px-5 py-2.5 bg-surface-container-high rounded-full font-label text-sm font-semibold text-on-surface-variant hover:bg-primary-container hover:text-on-primary-container transition-all duration-300">
|
||||
Set a reminder for reading
|
||||
</button>
|
||||
<button class="whitespace-nowrap px-5 py-2.5 bg-surface-container-high rounded-full font-label text-sm font-semibold text-on-surface-variant hover:bg-primary-container hover:text-on-primary-container transition-all duration-300">
|
||||
Summarize my week
|
||||
</button>
|
||||
</div>
|
||||
<!-- Chat Input Bar -->
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-primary/5 rounded-[2rem] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative flex items-center gap-2 bg-surface-container-low rounded-[2rem] p-2 pr-4 transition-all duration-300 focus-within:bg-surface-container-lowest focus-within:shadow-xl">
|
||||
<button class="w-12 h-12 flex items-center justify-center text-primary rounded-full hover:bg-primary/10 transition-colors">
|
||||
<span class="material-symbols-outlined" data-icon="mic">mic</span>
|
||||
</button>
|
||||
<input class="flex-1 bg-transparent border-none focus:ring-0 text-body-lg placeholder:text-outline-variant px-2" placeholder="Message Serene AI..." type="text"/>
|
||||
<button class="w-12 h-12 flex items-center justify-center bg-primary text-on-primary rounded-full hover:scale-105 active:scale-95 transition-all duration-300 shadow-lg shadow-primary/20">
|
||||
<span class="material-symbols-outlined" data-icon="send">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Global Bottom Nav -->
|
||||
<nav class="bg-[#f8faf9]/80 dark:bg-slate-950/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe no-line-rule tonal-layering-top shadow-[0_-10px_40px_rgba(45,52,51,0.05)] dark:shadow-none flex justify-around items-center w-full px-4 py-3">
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]" href="#">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="dashboard">dashboard</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Focus</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]" href="#">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="calendar_today">calendar_today</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Plan</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center bg-[#cbe9db] dark:bg-[#4a655a]/30 text-[#4a655a] dark:text-[#cbe9db] rounded-[1.5rem] px-5 py-2.5 transition-all duration-500" href="#">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="smart_toy" style="font-variation-settings: 'FILL' 1;">smart_toy</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">AI</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]" href="#">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="auto_graph">auto_graph</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Habits</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]" href="#">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="menu_book">menu_book</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Library</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -0,0 +1,267 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path - Daily Plan</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700;800&family=Plus_Jakarta_Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-secondary-container": "#40536d",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-container": "#eaefee",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-variant": "#dde4e3",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"tertiary": "#655b6f",
|
||||
"error": "#a83836",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"on-background": "#2d3433",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary": "#4e607b",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-error-container": "#6e0a12",
|
||||
"outline": "#757c7b",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-surface-variant": "#596060",
|
||||
"primary": "#4a655a",
|
||||
"on-error": "#fff7f6",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"outline-variant": "#acb3b2",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"primary-dim": "#3e594e",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-primary": "#e5fff2",
|
||||
"background": "#f8faf9",
|
||||
"primary-fixed": "#cbe9db"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.tonal-layering-top {
|
||||
background: linear-gradient(to bottom, rgba(241, 244, 243, 0.8), rgba(248, 250, 249, 0));
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface font-body text-on-surface min-h-screen">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 flex justify-between items-center w-full px-6 py-4 docked full-width top-0 sticky z-50 no-line-rule bg-[#f1f4f3] dark:bg-slate-900 flat no shadows">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden">
|
||||
<img class="w-full h-full object-cover" data-alt="portrait of a calm professional woman in soft natural lighting with a clean minimal background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD49eKVuQZyJyYBJcOmRRo39IaSMMjWKxi2ZPiDnfuKD_xnuNSc0pphkXzgRSltlM50Wo9bnXaIO5nhBWtx8tK8wi7TOm7a_4VTcHKEpjoMXPyJ0jayRV6uJgv5FVBdjxKuGqXRxMbr1jiZK4yKxjKlPJbZqwy1ODOShkyUi5MBJpz-pG9fLdGBO91RwkLxV_0dXiVXGY-IG32s1MTF5OxH2AePThaKuOgFQR81fRjTssWXg4g0KpoXrylm0q-QtXZ5YrNa0JBc2wLJ"/>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-[#4a655a] dark:text-[#cbe9db] font-['Manrope'] tracking-tight">Serene Path</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="text-[#acb3b2] dark:text-slate-500 hover:opacity-80 transition-opacity duration-300">
|
||||
<span class="material-symbols-outlined">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-5xl mx-auto px-6 pt-6 pb-32">
|
||||
<!-- View Toggle & Date Header -->
|
||||
<section class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
|
||||
<div>
|
||||
<p class="font-label text-sm text-secondary font-semibold uppercase tracking-[0.15em] mb-2">Tuesday</p>
|
||||
<h2 class="font-headline text-5xl font-extrabold tracking-tight text-on-surface">Oct 24</h2>
|
||||
</div>
|
||||
<div class="flex bg-surface-container-low p-1.5 rounded-full items-center">
|
||||
<button class="px-6 py-2.5 rounded-full bg-surface-container-lowest shadow-sm font-label text-sm font-bold text-primary transition-all">Day</button>
|
||||
<button class="px-6 py-2.5 rounded-full text-outline font-label text-sm font-medium hover:text-on-surface transition-all">Week</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Timeline Grid -->
|
||||
<div class="relative grid grid-cols-[80px_1fr] gap-x-8">
|
||||
<!-- Time Markers -->
|
||||
<div class="space-y-[80px] pt-4">
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">08:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">09:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">10:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">11:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">12:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">13:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">14:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">15:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">16:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">17:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">18:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">19:00</div>
|
||||
<div class="font-label text-xs font-bold text-outline-variant text-right pr-4">20:00</div>
|
||||
</div>
|
||||
<!-- Content Area -->
|
||||
<div class="relative bg-surface-container-low/30 rounded-3xl min-h-[1040px] border border-surface-container-high/50">
|
||||
<!-- Grid Horizontal Lines -->
|
||||
<div class="absolute inset-0 flex flex-col pointer-events-none">
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
<div class="h-[92px] border-b border-surface-container-high/40"></div>
|
||||
</div>
|
||||
<!-- Task Blocks -->
|
||||
<!-- Sleep Data (Apple Health Integration) -->
|
||||
<div class="absolute top-0 left-4 right-4 h-[120px] bg-tertiary-container/30 backdrop-blur-md rounded-2xl p-4 flex flex-col justify-between border-l-4 border-tertiary shadow-sm">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline font-bold text-tertiary">Sleep Cycle</h3>
|
||||
<span class="material-symbols-outlined text-tertiary text-lg" style="font-variation-settings: 'FILL' 1;">favorite</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-label text-xs font-bold text-on-tertiary-container uppercase tracking-wider">Health Data • 7h 20m</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Morning Routine -->
|
||||
<div class="absolute top-[140px] left-4 right-4 h-[80px] bg-primary-container rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-on-primary-container/10 rounded-full flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-on-primary-container">light_mode</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-headline font-bold text-on-primary-container">Morning Routine</h3>
|
||||
<p class="font-label text-xs text-on-primary-container/70">Meditation & Journaling</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-label text-xs font-bold text-on-primary-container/60">09:30 - 10:15</span>
|
||||
</div>
|
||||
<!-- Deep Work Block -->
|
||||
<div class="absolute top-[240px] left-4 right-4 h-[220px] bg-primary rounded-2xl p-6 shadow-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h3 class="font-headline text-2xl font-bold text-on-primary">Deep Work</h3>
|
||||
<button class="w-8 h-8 rounded-full bg-on-primary/10 flex items-center justify-center text-on-primary">
|
||||
<span class="material-symbols-outlined text-sm">more_horiz</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="font-body text-on-primary/80 max-w-sm">Focusing on the Product Strategy Deck and Quarter 4 planning. Phone in DND mode.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-3 py-1 bg-on-primary/10 rounded-full font-label text-[10px] font-bold text-on-primary uppercase tracking-widest">Focus Mode</span>
|
||||
<div class="h-1 w-1 rounded-full bg-on-primary/30"></div>
|
||||
<span class="font-label text-xs font-medium text-on-primary/70">10:45 - 12:45</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lunch Break -->
|
||||
<div class="absolute top-[480px] left-4 right-4 h-[70px] bg-surface-container-high rounded-2xl px-6 flex items-center justify-between border-dashed border-2 border-outline-variant/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-secondary">restaurant</span>
|
||||
<h3 class="font-headline font-bold text-on-surface-variant">Lunch Break</h3>
|
||||
</div>
|
||||
<span class="font-label text-xs font-medium text-outline">13:00 - 13:45</span>
|
||||
</div>
|
||||
<!-- Client Meeting -->
|
||||
<div class="absolute top-[570px] left-4 right-4 h-[90px] bg-secondary-container rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex -space-x-2">
|
||||
<img class="w-8 h-8 rounded-full border-2 border-secondary-container object-cover" data-alt="headshot of a smiling man with glasses" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAz7ESSe-84qXiBDvD4uadJMFf9550mrVSy0fTyFvnsBteXsKz_0dhRXy3PXY3emgvqKUhp4BL1ma3ttDbU8YaDUOaAprKhuWHK3P-J9w0OMu4cNy1jXB-O0SZUCzDcHW07VqM1d7UpUdfAUfyUEzYgotxvw2lOJpNl84rJiqgiTSEehqmZefB52e0oaPiDKrCof7FzSvvdTHsI5RwWAobb_iupZqghGgyaENH4GY5ghsbRjPztlJocL3N--zFd2iopmyOT_RRnDs97"/>
|
||||
<img class="w-8 h-8 rounded-full border-2 border-secondary-container object-cover" data-alt="headshot of a confident woman in a blazer" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBqrlyGgX_5i_lRONU78TaViRxqEky5ORJslxtzjoKmQEvhQwUe3QJbNzfwAai-moc6IaD5fkTwcLBDBOOa8PiXqUO49dV3ViRpJX1eGp_rX0PAXrzPXmwCCgTsRbd-Uowb1BxhW25VYWVIRveFP1VWfobcRQJYoMJxrGb9t3P6SwfMF2aRzmLI4kxLpWQMHpUi5bObYCGCcd1ZbLOTHve7fHicEc99qXbuvAKmLG1AVTS0etWXgVYEnu8QcYhi-ldGRlrY7F7xHFbx"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-headline font-bold text-on-secondary-container">Creative Review</h3>
|
||||
<p class="font-label text-xs text-on-secondary-container/70">With Design Team</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-label text-xs font-bold text-on-secondary-container/60">14:00 - 15:00</span>
|
||||
</div>
|
||||
<!-- Afternoon Walk -->
|
||||
<div class="absolute top-[750px] left-4 right-4 h-[60px] bg-tertiary-fixed-dim/40 rounded-2xl px-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-tertiary">directions_walk</span>
|
||||
<h3 class="font-headline font-bold text-on-tertiary-container">Mindful Walk</h3>
|
||||
</div>
|
||||
<span class="font-label text-xs font-medium text-on-tertiary-fixed-variant">16:30 - 17:15</span>
|
||||
</div>
|
||||
<!-- FAB Trigger Line (Visual Current Time Indicator) -->
|
||||
<div class="absolute top-[680px] left-0 right-0 h-0.5 bg-error/40 flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-error ml-[-6px] shadow-sm shadow-error/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Floating Action Button -->
|
||||
<button class="fixed bottom-24 right-6 w-14 h-14 bg-primary text-on-primary rounded-full shadow-[0_10px_30px_rgba(74,101,90,0.3)] flex items-center justify-center scale-100 hover:scale-105 active:scale-95 transition-all z-40">
|
||||
<span class="material-symbols-outlined text-3xl">add</span>
|
||||
</button>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="bg-[#f8faf9]/80 dark:bg-slate-950/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe no-line-rule tonal-layering-top shadow-[0_-10px_40px_rgba(45,52,51,0.05)] dark:shadow-none flex justify-around items-center w-full px-4 py-3">
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]">
|
||||
<span class="material-symbols-outlined mb-1">dashboard</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Focus</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center bg-[#cbe9db] dark:bg-[#4a655a]/30 text-[#4a655a] dark:text-[#cbe9db] rounded-[1.5rem] px-5 py-2.5 transition-all duration-500 scale-90">
|
||||
<span class="material-symbols-outlined mb-1">calendar_today</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Plan</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]">
|
||||
<span class="material-symbols-outlined mb-1">smart_toy</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">AI</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]">
|
||||
<span class="material-symbols-outlined mb-1">auto_graph</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Habits</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db]">
|
||||
<span class="material-symbols-outlined mb-1">menu_book</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Library</span>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,239 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Serene Path</title>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--secondary-bg: #f8f9fa;
|
||||
--text-primary: #1a1c1e;
|
||||
--text-secondary: #5f6368;
|
||||
--accent-blue: #e8f0fe;
|
||||
--border-color: #dadce0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Google Sans', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--secondary-bg);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-color);
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo { font-weight: 500; font-size: 1.1rem; }
|
||||
|
||||
.greeting-container { margin-bottom: 32px; }
|
||||
.label-caps {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.greeting-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin-top: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
margin: 24px 0 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.focus-card {
|
||||
background: var(--accent-blue);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.focus-title { font-weight: 700; font-size: 1rem; margin-bottom: 8px; }
|
||||
.focus-desc { font-size: 0.9rem; color: #444; line-height: 1.5; margin-bottom: 20px; }
|
||||
|
||||
.btn-group { display: flex; gap: 12px; }
|
||||
.btn {
|
||||
border-radius: 100px;
|
||||
padding: 10px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-primary { background: var(--text-primary); color: white; }
|
||||
.btn-secondary { background: white; border: 1px solid var(--border-color); }
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.percentage { font-size: 1.5rem; font-weight: 700; }
|
||||
|
||||
.habit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.icon-done { color: #1e8e3e; }
|
||||
.icon-todo { color: var(--border-color); }
|
||||
|
||||
.energy-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.energy-chip {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.tool-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.tool-card .material-icons { margin-bottom: 8px; color: #1a73e8; }
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item.active { color: var(--text-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header>
|
||||
<div class="logo">Serene Path</div>
|
||||
<span class="material-icons">settings</span>
|
||||
</header>
|
||||
|
||||
<div class="greeting-container">
|
||||
<div class="label-caps">GOOD MORNING, ALEX</div>
|
||||
<div class="greeting-text">Today is a fresh canvas. Take a deep breath.</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Today's Focus 9:30 AM — 11:00 AM</div>
|
||||
<div class="focus-card">
|
||||
<div class="focus-title">DEEP WORK: INTERFACE REFINEMENT</div>
|
||||
<div class="focus-desc">Focus on the typography hierarchy and tonal layering transitions for the mobile navigation shell.</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary"><span class="material-icons">play_arrow</span> Continue Session</button>
|
||||
<button class="btn btn-secondary">Reschedule</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Reading Progress</div>
|
||||
<div class="progress-row">
|
||||
<div>
|
||||
<div style="font-weight: 700;">Building a Second Brain</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary);">Tiago Forte • 42 pages left</div>
|
||||
</div>
|
||||
<div class="percentage">65%</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">KEYSTONE HABITS</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 8px;">2/3 Done</div>
|
||||
<div class="habit-item">
|
||||
<span class="material-icons icon-done">check_circle</span>
|
||||
<span>Morning Meditation</span>
|
||||
</div>
|
||||
<div class="habit-item">
|
||||
<span class="material-icons icon-done">check_circle</span>
|
||||
<span>Deep Work Session</span>
|
||||
</div>
|
||||
<div class="habit-item">
|
||||
<span class="material-icons icon-todo">radio_button_unchecked</span>
|
||||
<span>20min Evening Walk</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">ENERGY CHECK-IN</div>
|
||||
<div style="font-size: 0.9rem;">How are you feeling right now? Listen to your body.</div>
|
||||
<div class="energy-grid">
|
||||
<div class="energy-chip">Drained</div>
|
||||
<div class="energy-chip">Balanced</div>
|
||||
<div class="energy-chip">Radiant</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header section-title">MANAGEMENT DECK</div>
|
||||
<div class="management-grid">
|
||||
<div class="tool-card"><span class="material-icons">ads_click</span><br>Google Ads</div>
|
||||
<div class="tool-card"><span class="material-icons">social_leaderboard</span><br>Facebook</div>
|
||||
<div class="tool-card"><span class="material-icons">analytics</span><br>Analytics</div>
|
||||
<div class="tool-card"><span class="material-icons">mail</span><br>Inbox</div>
|
||||
<div class="tool-card"><span class="material-icons">payments</span><br>Finance</div>
|
||||
<div class="tool-card"><span class="material-icons">add</span><br>Add Tool</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-bar">
|
||||
<a href="#" class="nav-item active"><span class="material-icons">dashboard</span>Focus</a>
|
||||
<a href="#" class="nav-item"><span class="material-icons">calendar_today</span>Plan</a>
|
||||
<a href="#" class="nav-item"><span class="material-icons">smart_toy</span>AI</a>
|
||||
<a href="#" class="nav-item"><span class="material-icons">auto_graph</span>Habits</a>
|
||||
<a href="#" class="nav-item"><span class="material-icons">menu_book</span>Library</a>
|
||||
</nav>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,323 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path | Habit & Identity Tracker</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=Plus+Jakarta+Sans:wght@300..800&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-secondary-container": "#40536d",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-container": "#eaefee",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-variant": "#dde4e3",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"tertiary": "#655b6f",
|
||||
"error": "#a83836",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"on-background": "#2d3433",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary": "#4e607b",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-error-container": "#6e0a12",
|
||||
"outline": "#757c7b",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-surface-variant": "#596060",
|
||||
"primary": "#4a655a",
|
||||
"on-error": "#fff7f6",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"outline-variant": "#acb3b2",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"primary-dim": "#3e594e",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-primary": "#e5fff2",
|
||||
"background": "#f8faf9",
|
||||
"primary-fixed": "#cbe9db"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body selection:bg-primary-container selection:text-on-primary-container">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 flex justify-between items-center w-full px-6 py-4 docked full-width top-0 sticky z-50 no-line-rule bg-[#f1f4f3] dark:bg-slate-900 flat no shadows">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden">
|
||||
<img class="w-full h-full object-cover" data-alt="professional portrait of a calm woman in a soft-lit studio with neutral earth tones" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC54N1fh97FMb60y8DmfYnS3DaiEd8HINjSzIXwOna5i3kvq6CA5IiGlkBUXxAs_R537Hgy7Z7bmlp4DA_UUxINVOXOe850ADHPnhSD1N7yAqlQJPLdsESO2TSah8tLoaNTEXlx54QJVzHS77oW7hO8ToCZnim4t1-JU0FcNzHPbwpE0vAIQ7oz4ObhTOuyjePph_dIFrYJTxaz1mKCrqgPC1rsb0yrz0hC4MyhdcLqXuonW7l5P6h7zwJ4D0luoBY5_YyYwpkoEZw8"/>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-[#4a655a] dark:text-[#cbe9db] font-headline tracking-tight">Serene Path</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 hover:opacity-80 transition-opacity duration-300 scale-95 duration-300 ease-in-out text-[#4a655a] dark:text-[#cbe9db]">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-5xl mx-auto px-6 py-10 pb-32">
|
||||
<!-- Identity Header: "Who I am becoming" -->
|
||||
<section class="mb-12">
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
|
||||
<div class="max-w-2xl">
|
||||
<span class="font-label text-label-sm text-tertiary uppercase tracking-[0.2em] mb-3 block">Foundation</span>
|
||||
<h1 class="font-headline text-5xl font-extrabold tracking-tight text-on-surface mb-4">Who I am becoming</h1>
|
||||
<p class="text-on-surface-variant text-lg leading-relaxed">Focusing on identity-based growth rather than just checking boxes.</p>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="bg-surface-container-low rounded-xl p-6 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-primary-container flex items-center justify-center text-primary">
|
||||
<span class="material-symbols-outlined" data-icon="auto_awesome">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-label text-[10px] uppercase tracking-wider text-outline">Current Focus</p>
|
||||
<p class="font-bold text-on-surface">The Disciplined Reader</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Identity Cards (Bento Style) -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="md:col-span-2 bg-gradient-to-br from-primary to-primary-dim rounded-xl p-8 text-on-primary relative overflow-hidden">
|
||||
<div class="relative z-10">
|
||||
<span class="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-bold uppercase tracking-widest mb-6 inline-block">Primary Identity</span>
|
||||
<h2 class="text-3xl font-bold mb-4 leading-tight italic">"I am a person who prioritizes deep knowledge over superficial distraction."</h2>
|
||||
<div class="flex items-center gap-2 mt-8">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="verified" style="font-variation-settings: 'FILL' 1;">verified</span>
|
||||
<span class="font-label text-sm tracking-wide">Rooted in 12 consistent days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-container/10 rounded-full -mr-20 -mt-20 blur-3xl"></div>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest rounded-xl p-8 border-outline-variant/15 border flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 class="font-headline text-xl font-bold text-on-surface mb-2">Secondary Shift</h3>
|
||||
<p class="text-on-surface-variant italic">"I am a healthy athlete who treats my body with respect."</p>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex -space-x-2">
|
||||
<div class="w-8 h-8 rounded-full bg-secondary-container flex items-center justify-center text-xs border-2 border-white">M</div>
|
||||
<div class="w-8 h-8 rounded-full bg-secondary-container flex items-center justify-center text-xs border-2 border-white">T</div>
|
||||
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-xs border-2 border-white text-white">W</div>
|
||||
</div>
|
||||
<span class="text-primary font-bold text-sm">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Burnout Alert Section (Conditional UI) -->
|
||||
<section class="mb-12">
|
||||
<div class="bg-error-container/10 border-l-4 border-error-container p-6 rounded-r-xl flex flex-col md:flex-row items-center gap-6">
|
||||
<div class="w-14 h-14 bg-error-container rounded-full flex items-center justify-center text-on-error-container animate-pulse shrink-0">
|
||||
<span class="material-symbols-outlined text-3xl" data-icon="warning" style="font-variation-settings: 'FILL' 1;">warning</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="font-headline text-lg font-bold text-on-error-container mb-1">Burnout Alert: Momentum is Fading</h4>
|
||||
<p class="text-on-error-container/80 text-sm">You've missed 4 habits in the last 24 hours. The path to growth requires rhythm, but also recovery.</p>
|
||||
</div>
|
||||
<button class="bg-error-container text-on-error-container font-label text-xs font-bold px-6 py-3 rounded-full hover:brightness-95 transition-all shrink-0">
|
||||
REST & RECHARGE
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Keystone Habits & Progress -->
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
<!-- Habit List -->
|
||||
<div class="lg:col-span-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="font-headline text-2xl font-bold">Keystone Habits</h3>
|
||||
<button class="text-primary font-label text-sm font-bold flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-lg" data-icon="add_circle">add_circle</span>
|
||||
NEW HABIT
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Habit Item 1 -->
|
||||
<div class="group bg-surface-container-lowest hover:bg-surface-bright transition-all p-6 rounded-2xl flex items-center gap-6 shadow-sm border border-outline-variant/5">
|
||||
<button class="w-14 h-14 rounded-full border-4 border-primary-container flex items-center justify-center text-primary-container hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl" data-icon="check_circle">check_circle</span>
|
||||
</button>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h4 class="font-headline font-bold text-lg text-on-surface">Morning Meditation</h4>
|
||||
<span class="bg-secondary-container/50 text-on-secondary-container px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-tighter">Identity: Calm Observer</span>
|
||||
</div>
|
||||
<p class="text-on-surface-variant text-sm">"I don't react, I observe my thoughts without judgment."</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs font-label text-outline uppercase tracking-widest">Streak</p>
|
||||
<p class="text-2xl font-bold text-primary">12d</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Habit Item 2 -->
|
||||
<div class="group bg-surface-container-lowest hover:bg-surface-bright transition-all p-6 rounded-2xl flex items-center gap-6 shadow-sm border border-outline-variant/5">
|
||||
<button class="w-14 h-14 rounded-full bg-primary flex items-center justify-center text-on-primary">
|
||||
<span class="material-symbols-outlined text-2xl" data-icon="done_all" style="font-variation-settings: 'FILL' 1;">done_all</span>
|
||||
</button>
|
||||
<div class="flex-grow opacity-60">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h4 class="font-headline font-bold text-lg text-on-surface line-through">Read 20 Pages</h4>
|
||||
<span class="bg-primary-container text-on-primary-container px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-tighter">Identity: Lifelong Learner</span>
|
||||
</div>
|
||||
<p class="text-on-surface-variant text-sm">"Knowledge is the compound interest of my identity."</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs font-label text-outline uppercase tracking-widest">Streak</p>
|
||||
<p class="text-2xl font-bold text-primary">45d</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Habit Item 3 -->
|
||||
<div class="group bg-surface-container-lowest hover:bg-surface-bright transition-all p-6 rounded-2xl flex items-center gap-6 shadow-sm border border-outline-variant/5">
|
||||
<button class="w-14 h-14 rounded-full border-4 border-primary-container flex items-center justify-center text-primary-container hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl" data-icon="check_circle">check_circle</span>
|
||||
</button>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h4 class="font-headline font-bold text-lg text-on-surface">Evening Reflection</h4>
|
||||
<span class="bg-tertiary-container text-on-tertiary-container px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-tighter">Identity: Intentional Soul</span>
|
||||
</div>
|
||||
<p class="text-on-surface-variant text-sm">"I review my day to refine my tomorrow."</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs font-label text-outline uppercase tracking-widest">Streak</p>
|
||||
<p class="text-2xl font-bold text-primary">0d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Visualization / Weekly Summary -->
|
||||
<div class="lg:col-span-4">
|
||||
<div class="bg-surface-container rounded-3xl p-8 sticky top-24">
|
||||
<h3 class="font-headline text-xl font-bold mb-6">Weekly Rhythm</h3>
|
||||
<div class="space-y-6">
|
||||
<!-- Chart Mock -->
|
||||
<div class="flex items-end justify-between h-32 gap-2 mb-8">
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-primary-fixed-dim rounded-t-lg h-[60%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">M</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-primary-fixed-dim rounded-t-lg h-[85%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">T</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-primary rounded-t-lg h-[100%]"></div>
|
||||
<span class="text-[10px] font-label text-outline font-bold">W</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[20%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">T</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[15%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">F</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[10%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">S</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 flex-1">
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[5%]"></div>
|
||||
<span class="text-[10px] font-label text-outline">S</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-label text-on-surface-variant">Weekly Completion</span>
|
||||
<span class="text-sm font-bold text-primary">78%</span>
|
||||
</div>
|
||||
<div class="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-primary to-primary-fixed-dim w-[78%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-6 border-t border-outline-variant/10">
|
||||
<p class="font-label text-xs text-outline leading-relaxed mb-4">Identity reinforcement suggests you are 14 days away from these habits feeling "automatic." Keep going.</p>
|
||||
<button class="w-full py-4 bg-surface-container-lowest rounded-2xl text-on-surface font-bold text-sm hover:bg-white transition-all shadow-sm">
|
||||
View Deep Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Bottom Navigation Bar -->
|
||||
<nav class="fixed bottom-0 w-full z-50 pb-safe bg-[#f8faf9]/80 dark:bg-slate-950/80 backdrop-blur-xl no-line-rule tonal-layering-top shadow-[0_-10px_40px_rgba(45,52,51,0.05)] dark:shadow-none">
|
||||
<div class="flex justify-around items-center w-full px-4 py-3">
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] scale-90 duration-500 ease-in-out" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Focus</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] scale-90 duration-500 ease-in-out" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Plan</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] scale-90 duration-500 ease-in-out" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="smart_toy">smart_toy</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">AI</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center bg-[#cbe9db] dark:bg-[#4a655a]/30 text-[#4a655a] dark:text-[#cbe9db] rounded-[1.5rem] px-5 py-2.5 transition-all duration-500 scale-90 duration-500 ease-in-out" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="auto_graph" style="font-variation-settings: 'FILL' 1;">auto_graph</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Habits</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] scale-90 duration-500 ease-in-out" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="menu_book">menu_book</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Library</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,287 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path - Library</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Plus_Jakarta_Sans:wght@500;600;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-secondary-container": "#40536d",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-container": "#eaefee",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-variant": "#dde4e3",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"tertiary": "#655b6f",
|
||||
"error": "#a83836",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"on-background": "#2d3433",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary": "#4e607b",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-error-container": "#6e0a12",
|
||||
"outline": "#757c7b",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-surface-variant": "#596060",
|
||||
"primary": "#4a655a",
|
||||
"on-error": "#fff7f6",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"outline-variant": "#acb3b2",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"primary-dim": "#3e594e",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-primary": "#e5fff2",
|
||||
"background": "#f8faf9",
|
||||
"primary-fixed": "#cbe9db"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.tonal-layering-top { box-shadow: 0 -1px 0 0 rgba(0,0,0,0.02); }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface font-body text-on-surface antialiased">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 docked full-width top-0 sticky z-50 flex justify-between items-center w-full px-6 py-4 no-line-rule bg-[#f1f4f3] dark:bg-slate-900 flat no shadows">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden">
|
||||
<img class="w-full h-full object-cover" data-alt="softly lit portrait of a calm woman with a neutral background, high-end editorial photography style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDU25RSnEY9tY1-qYqlM11PIxf7jEh2sXopp1_idFCTCkKrRigPSMDoS39foFoEPDAO6pFMF1HiWceeP7jmtcsEBagEfh2bJfnP4Sbm7ZeKi4IcaEXzTCs_d14hftSuBjjBlIZMNd8AutmAVMpRujwqrFiHBqSUe0cJZgwg54QWAdxD4uoS4mKqrasws6E1dWIo8JgYtD0odEL8pj0qCtELbtrxaL-AiHRg_moyvFXMGyPFRIztJtubtuqblMp1dHIDEt0lq9hXtOeC"/>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-[#4a655a] dark:text-[#cbe9db] font-['Manrope'] tracking-tight">Serene Path</span>
|
||||
</div>
|
||||
<button class="text-[#4a655a] dark:text-[#cbe9db] hover:opacity-80 transition-opacity duration-300 transform active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="max-w-5xl mx-auto px-6 pt-8 pb-32">
|
||||
<!-- Hero Section: Daily Goal -->
|
||||
<section class="mb-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 items-end">
|
||||
<div class="md:col-span-8">
|
||||
<h1 class="text-4xl md:text-5xl font-headline font-extrabold tracking-tight text-on-surface mb-2">Library</h1>
|
||||
<p class="text-on-surface-variant font-label text-sm tracking-wide uppercase">Curate your mind's expansion</p>
|
||||
</div>
|
||||
<div class="md:col-span-4">
|
||||
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
|
||||
<div class="relative z-10">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="font-label text-xs font-bold text-primary tracking-widest uppercase">Daily Goal</span>
|
||||
<span class="font-label text-xs font-bold text-on-surface">45 / 60 min</span>
|
||||
</div>
|
||||
<div class="w-full h-3 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-primary to-primary-fixed-dim rounded-full w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Continue Reading Widget (Asymmetric Layout) -->
|
||||
<section class="mb-16">
|
||||
<div class="bg-primary text-on-primary rounded-xl overflow-hidden flex flex-col md:flex-row shadow-2xl shadow-primary/10">
|
||||
<div class="md:w-1/3 h-64 md:h-auto overflow-hidden">
|
||||
<img class="w-full h-full object-cover" data-alt="abstract minimalist book cover with muted sage and cream organic shapes, soft paper texture" src="https://lh3.googleusercontent.com/aida-public/AB6AXuALRJjEAYLwO1uKsH4jwYuenBVv3BvFF52ZW-vJsBjHgn-GS1nGUK1Kznxt6ArYAQumRl0zYWB2gdmaUCGSrT7ziB6662hVhW0K8tw5wJw80T6U-Cz3VecK9XGa0GYO0dCHEFc0XeT5UiqP6MCb90hpBQMX56HWmtYhb966RULfbQ7J7m5lDWYpRPbpE8Zo1kgB5DdVjuPVg_qOOe6gWv2QKTTk89xrzmNRMiKNTgdMTV8ErisxMsABf9h5NQIFO4A1yvOwmkBzb6Vv"/>
|
||||
</div>
|
||||
<div class="md:w-2/3 p-8 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="auto_stories">auto_stories</span>
|
||||
<span class="font-label text-[10px] font-bold uppercase tracking-[0.2em]">Continue Reading</span>
|
||||
</div>
|
||||
<h2 class="text-3xl font-headline font-bold mb-2">The Architecture of Stillness</h2>
|
||||
<p class="text-on-primary/70 text-sm mb-8 leading-relaxed max-w-md">Discover the profound impact of intentional physical environments on mental clarity and spiritual peace.</p>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
<button class="bg-on-primary text-primary px-8 py-3 rounded-full font-label text-xs font-bold tracking-wider hover:opacity-90 transition-opacity">
|
||||
RESUME (65%)
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-on-primary/10 flex items-center justify-center cursor-pointer hover:bg-on-primary/20 transition-colors">
|
||||
<span class="material-symbols-outlined text-xl" data-icon="bookmark">bookmark</span>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-on-primary/10 flex items-center justify-center cursor-pointer hover:bg-on-primary/20 transition-colors">
|
||||
<img class="w-5 h-5 opacity-80" data-alt="Apple Books app icon on a white square background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDKakrxK95LkAvHeedsPl4oou9-M5jn1SgDuYpf0MFlddJiNiVpSro_hVxjhqVxmge6P_jzQBoW_sRikxylyKR9QWfhUF5rNY4If3Z1RD_JMUCA2FhhBl2DCk0YpUSHHrgGYcswi7MXiC1CO8g23zXhASHHBHgdaTn8a8BsdPfPUGW2z-fshnFRGTZX7sh6_gbwpd1ebq3nCu9yKr_dCFwK1sy_kb7ySVtarDpc7hUio4-MLahyM8wRJCrFwKYryXllRv3oEmt_0gG2"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- The Shelf (Bento Grid) -->
|
||||
<section class="mb-16">
|
||||
<div class="flex justify-between items-baseline mb-8">
|
||||
<h3 class="text-2xl font-headline font-bold text-on-surface">Your Collection</h3>
|
||||
<button class="text-primary font-label text-xs font-bold tracking-widest uppercase hover:underline">View All</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<!-- Book 1 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="relative aspect-[3/4] rounded-xl overflow-hidden bg-surface-container-high mb-4 transition-transform duration-500 group-hover:scale-[1.02]">
|
||||
<img class="w-full h-full object-cover" data-alt="aesthetic hardback book cover with simple typography and natural linen texture" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAkMDYviMy3jBSuSlwLl39b84QGALZJh1oU3qPe1sxyVj0Io3uS1m8z9FObax_LXbABst3EvYPAe-UqjS4y_-Gfs3TWh-bA4rqccIhsAyIwFr68Gl0fn_CRQtHB2jSgKZLvgP3RVqBnXtXbqr1VTS17KGL4fjn9FOxsd0_u0rK0KmzUCgfbnWAshsjOb9bskPvbinnhqeDepBBIcexi_f54gzgDM_xZfJwoc9mKc34Av2MyCVkzGHZ99R68ph2607fFP5ddj5qw1Juf"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4">
|
||||
<span class="text-white text-xs font-label font-bold uppercase tracking-widest">Open PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="font-headline font-bold text-sm mb-1 line-clamp-1">Mindful Breathing</h4>
|
||||
<p class="text-[10px] font-label font-semibold text-on-surface-variant uppercase tracking-wider mb-3">Dr. Aris Thorne</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-1 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full w-[82%]"></div>
|
||||
</div>
|
||||
<span class="text-[9px] font-label font-bold text-primary">82%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Book 2 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="relative aspect-[3/4] rounded-xl overflow-hidden bg-surface-container-high mb-4 transition-transform duration-500 group-hover:scale-[1.02]">
|
||||
<img class="w-full h-full object-cover" data-alt="red book cover with gold foil accents, scholarly and elegant visual style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCgNxP_SYl7o4L0gL6iaFDlW4K1_qwrnX-gsKyOzi1rAebIY7UUUjnt-NyZ94LlcGHAxFBLM0ZP_ru6guRzopt-TulwwqcbWdXJTqgJSJcYrQzy1v3N2IZX8eaoDV8V9c1pUXgTyHAttpoLYObLXh--NvvpHd4f11nJWUyWV616isPNDLj0YWhLLiKPLvdgEcpFuBcImRVz-31e7aLqjYhlkkx3xUYRv0DRmG2XFPNaoccrehn_ICWZ_ct-p16INBzr_I7Z9_dAh1rk"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4">
|
||||
<span class="text-white text-xs font-label font-bold uppercase tracking-widest">Open PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="font-headline font-bold text-sm mb-1 line-clamp-1">Quietude</h4>
|
||||
<p class="text-[10px] font-label font-semibold text-on-surface-variant uppercase tracking-wider mb-3">Elena Vance</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-1 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full w-[24%]"></div>
|
||||
</div>
|
||||
<span class="text-[9px] font-label font-bold text-primary">24%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Book 3 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="relative aspect-[3/4] rounded-xl overflow-hidden bg-surface-container-high mb-4 transition-transform duration-500 group-hover:scale-[1.02]">
|
||||
<img class="w-full h-full object-cover" data-alt="illustrated book cover showing stars and mountains in a minimalist woodcut style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAk1adUlUMova9E31Mv0wNIMuNic-DgJ19IfldCDNG7Iek6SYtga6uiIl3HWZg2UYVu0Jf9tFp6G22Ie7oCoCX5mCdPilvqySx80OHHlWr0luTN7H8ybLeNcAZykr_leSwnFGWiNBZiS9XdI-QOSq2lws7oSjTzzDMtj6aJe2O9mvYUMHhYEDIITQJmMxjYmG5Lr-j9xu0DTtbnB2FpcggcU-PNwwje-FYsfvwVKj1YtJG4IgB3-Ex-8zD48Z31S_VK47VgLiWBwe3s"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4">
|
||||
<span class="text-white text-xs font-label font-bold uppercase tracking-widest">Open PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="font-headline font-bold text-sm mb-1 line-clamp-1">Nature's Rythm</h4>
|
||||
<p class="text-[10px] font-label font-semibold text-on-surface-variant uppercase tracking-wider mb-3">Marcus Sol</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-1 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full w-[45%]"></div>
|
||||
</div>
|
||||
<span class="text-[9px] font-label font-bold text-primary">45%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add New -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="relative aspect-[3/4] rounded-xl border-2 border-dashed border-outline-variant/30 flex flex-col items-center justify-center gap-3 hover:bg-surface-container-low transition-colors duration-300">
|
||||
<span class="material-symbols-outlined text-4xl text-outline-variant" data-icon="add_circle">add_circle</span>
|
||||
<span class="font-label text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Upload PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Secondary Collection (Asymmetric List) -->
|
||||
<section class="mb-16">
|
||||
<h3 class="text-2xl font-headline font-bold text-on-surface mb-8">Recent Articles</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface-container-lowest p-5 rounded-xl flex items-center gap-6 hover:shadow-sm transition-shadow cursor-pointer">
|
||||
<div class="w-16 h-16 rounded-lg bg-surface-container-high flex-shrink-0 flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-primary text-3xl" data-icon="description">description</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h5 class="font-headline font-bold text-base mb-1">Journal of Cognitive Rest</h5>
|
||||
<p class="text-xs text-on-surface-variant line-clamp-1">Exploring the synaptic benefits of 15-minute digital detoxes.</p>
|
||||
</div>
|
||||
<div class="text-right hidden sm:block">
|
||||
<p class="text-[10px] font-label font-bold uppercase tracking-widest text-primary mb-1">Completed</p>
|
||||
<p class="text-[10px] font-label text-on-surface-variant">2 days ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest p-5 rounded-xl flex items-center gap-6 hover:shadow-sm transition-shadow cursor-pointer">
|
||||
<div class="w-16 h-16 rounded-lg bg-surface-container-high flex-shrink-0 flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-secondary text-3xl" data-icon="article">article</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h5 class="font-headline font-bold text-base mb-1">The Art of Soft Focus</h5>
|
||||
<p class="text-xs text-on-surface-variant line-clamp-1">Balancing deep work with peripheral awareness for creativity.</p>
|
||||
</div>
|
||||
<div class="text-right hidden sm:block">
|
||||
<p class="text-[10px] font-label font-bold uppercase tracking-widest text-secondary mb-1">12 min left</p>
|
||||
<p class="text-[10px] font-label text-on-surface-variant">Today</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="bg-[#f8faf9]/80 dark:bg-slate-950/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe no-line-rule tonal-layering-top shadow-[0_-10px_40px_rgba(45,52,51,0.05)] dark:shadow-none flex justify-around items-center w-full px-4 py-3">
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] cursor-pointer transform hover:scale-90 active:scale-90">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="dashboard">dashboard</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Focus</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] cursor-pointer transform hover:scale-90 active:scale-90">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="calendar_today">calendar_today</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Plan</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] cursor-pointer transform hover:scale-90 active:scale-90">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="smart_toy">smart_toy</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">AI</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] cursor-pointer transform hover:scale-90 active:scale-90">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="auto_graph">auto_graph</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Habits</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center bg-[#cbe9db] dark:bg-[#4a655a]/30 text-[#4a655a] dark:text-[#cbe9db] rounded-[1.5rem] px-5 py-2.5 transition-all duration-500 transform scale-90">
|
||||
<span class="material-symbols-outlined mb-1" data-icon="menu_book">menu_book</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider">Library</span>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -0,0 +1,259 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path | Focus Sprint</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Plus+Jakarta+Sans:wght@500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-secondary-container": "#40536d",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-container": "#eaefee",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-variant": "#dde4e3",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"tertiary": "#655b6f",
|
||||
"error": "#a83836",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"on-background": "#2d3433",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary": "#4e607b",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-error-container": "#6e0a12",
|
||||
"outline": "#757c7b",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-surface-variant": "#596060",
|
||||
"primary": "#4a655a",
|
||||
"on-error": "#fff7f6",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"outline-variant": "#acb3b2",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"primary-dim": "#3e594e",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-primary": "#e5fff2",
|
||||
"background": "#f8faf9",
|
||||
"primary-fixed": "#cbe9db"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.zen-gradient {
|
||||
background: linear-gradient(135deg, #f8faf9 0%, #eaefee 100%);
|
||||
}
|
||||
.timer-glow {
|
||||
box-shadow: 0 0 50px rgba(74, 101, 90, 0.05);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body selection:bg-primary-container">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 text-[#4a655a] dark:text-[#cbe9db] docked full-width top-0 sticky z-50 no-line-rule bg-[#f1f4f3] dark:bg-slate-900 flat no shadows flex justify-between items-center w-full px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-high">
|
||||
<img alt="user profile picture" class="w-full h-full object-cover" data-alt="minimalist professional avatar of a calm individual with clean aesthetic and soft lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDFm9flLrERRx-ZlB3atcJy4lX2S5YFwrUTAsRSQQGILoGrAyH48H1wbTv14TNdVe1I0WwnthXrxvEmtpyyhNNdgSZW8ad-toXlA1aGMGo0Jx7StW7NpDTlTu7rpbEKeY1zH0H8CkA0oQVkr-1QoxevD6Entr_QcRCyCRkhiVlQ6vm_ilqIdWrLZ3ULV8JYpkJy6Il8VPwOx-BS5hAiQl69pr-oao_rlDuLfkgLRL1JnzpHqM-iZJbhrhuC1pghVknyCpLsuElLiQGR"/>
|
||||
</div>
|
||||
<h1 class="font-['Manrope'] font-bold tracking-tight text-[#2d3433] dark:text-slate-100 text-xl font-bold text-[#4a655a] dark:text-[#cbe9db]">Serene Path</h1>
|
||||
</div>
|
||||
<button class="hover:opacity-80 transition-opacity duration-300 active:scale-95 duration-300 ease-in-out">
|
||||
<span class="material-symbols-outlined text-2xl">settings</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="max-w-7xl mx-auto px-6 py-8 pb-32">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
<!-- Left Column: Timer & Zen Mode -->
|
||||
<section class="lg:col-span-7 flex flex-col gap-8">
|
||||
<div class="bg-surface-container-lowest rounded-[2rem] p-12 flex flex-col items-center justify-center relative overflow-hidden timer-glow">
|
||||
<!-- Subtle Background Decoration -->
|
||||
<div class="absolute top-0 left-0 w-full h-full opacity-5 pointer-events-none">
|
||||
<svg height="100%" preserveaspectratio="none" viewbox="0 0 100 100" width="100%">
|
||||
<circle class="text-primary" cx="50" cy="50" fill="none" r="40" stroke="currentColor" stroke-width="0.5"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="z-10 text-center">
|
||||
<p class="font-label text-label-md text-primary uppercase tracking-[0.2em] mb-4">Current Sprint: Deep Work</p>
|
||||
<h2 class="font-headline text-[8rem] md:text-[10rem] font-extrabold leading-none tracking-tighter text-on-surface">
|
||||
24:59
|
||||
</h2>
|
||||
<div class="flex items-center justify-center gap-8 mt-12">
|
||||
<button class="w-14 h-14 rounded-full flex items-center justify-center bg-surface-container-high text-primary hover:bg-primary-container transition-all">
|
||||
<span class="material-symbols-outlined text-3xl">skip_previous</span>
|
||||
</button>
|
||||
<button class="w-24 h-24 rounded-full flex items-center justify-center bg-primary text-on-primary shadow-xl hover:scale-105 transition-transform">
|
||||
<span class="material-symbols-outlined text-5xl" style="font-variation-settings: 'FILL' 1;">pause</span>
|
||||
</button>
|
||||
<button class="w-14 h-14 rounded-full flex items-center justify-center bg-surface-container-high text-primary hover:bg-primary-container transition-all">
|
||||
<span class="material-symbols-outlined text-3xl">skip_next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Session Progress -->
|
||||
<div class="bg-surface-container-low rounded-xl p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="font-label text-label-sm text-secondary font-bold uppercase tracking-wider">Daily Goal: 4 Sprints</span>
|
||||
<span class="font-label text-label-sm text-on-surface">2 / 4 Completed</span>
|
||||
</div>
|
||||
<div class="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-primary to-primary-fixed-dim w-1/2 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Right Column: Planning & Tasks -->
|
||||
<section class="lg:col-span-5 flex flex-col gap-6">
|
||||
<!-- Micro-break Settings -->
|
||||
<div class="bg-surface-container-low rounded-xl p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="material-symbols-outlined text-primary">eco</span>
|
||||
<h3 class="font-headline text-lg font-bold">Focus Strategy</h3>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1 bg-surface-container-lowest p-4 rounded-xl">
|
||||
<p class="font-label text-[10px] text-outline uppercase tracking-wider mb-1">Work</p>
|
||||
<p class="font-headline text-xl font-bold text-on-surface">25 <span class="text-sm font-normal text-outline">min</span></p>
|
||||
</div>
|
||||
<div class="flex-1 bg-surface-container-lowest p-4 rounded-xl">
|
||||
<p class="font-label text-[10px] text-outline uppercase tracking-wider mb-1">Break</p>
|
||||
<p class="font-headline text-xl font-bold text-on-surface">5 <span class="text-sm font-normal text-outline">min</span></p>
|
||||
</div>
|
||||
<button class="w-14 bg-primary-container text-on-primary-container rounded-xl flex items-center justify-center hover:opacity-80 transition-opacity">
|
||||
<span class="material-symbols-outlined">tune</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sprint Tasks -->
|
||||
<div class="bg-surface-container-lowest rounded-xl p-8 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h3 class="font-headline text-xl font-bold">Sprint Tasks</h3>
|
||||
<button class="text-primary font-label text-label-md font-bold flex items-center gap-1 hover:underline">
|
||||
<span class="material-symbols-outlined text-sm">add</span>
|
||||
Batch New Task
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Task Item: Active -->
|
||||
<div class="flex items-start gap-4 p-4 rounded-xl bg-primary-container/30 border-l-4 border-primary">
|
||||
<button class="mt-1 w-6 h-6 rounded-full border-2 border-primary flex items-center justify-center">
|
||||
<div class="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<p class="font-body text-body-lg font-bold text-on-primary-container">Refine Brand Identity Guidelines</p>
|
||||
<p class="font-label text-label-sm text-on-primary-fixed-variant mt-1">Priority focus for this session</p>
|
||||
</div>
|
||||
<span class="font-label text-label-sm text-primary font-bold">Current</span>
|
||||
</div>
|
||||
<!-- Task Item: Queued -->
|
||||
<div class="flex items-start gap-4 p-4 rounded-xl hover:bg-surface-container transition-colors group">
|
||||
<button class="mt-1 w-6 h-6 rounded-full border-2 border-outline-variant group-hover:border-primary transition-colors"></button>
|
||||
<div class="flex-1">
|
||||
<p class="font-body text-body-lg text-on-surface">Review Q3 Performance Metrics</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="px-2 py-0.5 rounded-full bg-secondary-container text-on-secondary-container font-label text-[10px] uppercase">Analytics</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Task Item: Queued -->
|
||||
<div class="flex items-start gap-4 p-4 rounded-xl hover:bg-surface-container transition-colors group">
|
||||
<button class="mt-1 w-6 h-6 rounded-full border-2 border-outline-variant group-hover:border-primary transition-colors"></button>
|
||||
<div class="flex-1">
|
||||
<p class="font-body text-body-lg text-on-surface">Client Proposal Draft</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="px-2 py-0.5 rounded-full bg-tertiary-container text-on-tertiary-container font-label text-[10px] uppercase">Sales</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deep Work Insight Card -->
|
||||
<div class="bg-tertiary text-on-tertiary rounded-xl p-6 relative overflow-hidden">
|
||||
<div class="absolute right-[-20px] bottom-[-20px] opacity-10">
|
||||
<span class="material-symbols-outlined text-[120px]" style="font-variation-settings: 'FILL' 1;">lightbulb</span>
|
||||
</div>
|
||||
<p class="font-label text-label-sm uppercase tracking-widest opacity-80 mb-2">Deep Work Insight</p>
|
||||
<p class="font-headline text-lg font-bold leading-relaxed z-10 relative">
|
||||
"Your focus is the most valuable currency you possess. Don't spend it on trivial distractions."
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-2 opacity-80">
|
||||
<span class="material-symbols-outlined text-sm">auto_stories</span>
|
||||
<span class="font-label text-label-sm italic">Strategy: Task Batching</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="bg-[#f8faf9]/80 dark:bg-slate-950/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe shadow-[0_-10px_40px_rgba(45,52,51,0.05)] dark:shadow-none flex justify-around items-center w-full px-4 py-3">
|
||||
<div class="flex flex-col items-center justify-center bg-[#cbe9db] dark:bg-[#4a655a]/30 text-[#4a655a] dark:text-[#cbe9db] rounded-[1.5rem] px-5 py-2.5 transition-all duration-500 active:scale-90 duration-500 ease-in-out">
|
||||
<span class="material-symbols-outlined" data-icon="dashboard" style="font-variation-settings: 'FILL' 1;">dashboard</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Focus</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] active:scale-90 duration-500 ease-in-out">
|
||||
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Plan</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] active:scale-90 duration-500 ease-in-out">
|
||||
<span class="material-symbols-outlined" data-icon="smart_toy">smart_toy</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">AI</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] active:scale-90 duration-500 ease-in-out">
|
||||
<span class="material-symbols-outlined" data-icon="auto_graph">auto_graph</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Habits</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-[#acb3b2] dark:text-slate-500 px-5 py-2.5 transition-all duration-500 hover:text-[#4a655a] dark:hover:text-[#cbe9db] active:scale-90 duration-500 ease-in-out">
|
||||
<span class="material-symbols-outlined" data-icon="menu_book">menu_book</span>
|
||||
<span class="font-['Plus_Jakarta_Sans'] text-[10px] font-semibold uppercase tracking-wider mt-1">Library</span>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -0,0 +1,316 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path AI Assistant</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"on-primary-container": "#3d574d",
|
||||
"on-error-container": "#6e0a12",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"surface": "#f8faf9",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"surface-variant": "#dde4e3",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"primary-dim": "#3e594e",
|
||||
"primary": "#4a655a",
|
||||
"on-primary": "#e5fff2",
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"on-error": "#fff7f6",
|
||||
"background": "#f8faf9",
|
||||
"error": "#a83836",
|
||||
"outline-variant": "#acb3b2",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"primary-container": "#cbe9db",
|
||||
"tertiary-dim": "#594f63",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"error-container": "#fa746f",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"surface-container": "#eaefee",
|
||||
"primary-fixed": "#cbe9db",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"error-dim": "#67040d",
|
||||
"on-secondary-container": "#40536d",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"tertiary": "#655b6f",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"outline": "#757c7b",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"on-background": "#2d3433",
|
||||
"secondary-dim": "#42546f",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-surface-variant": "#596060",
|
||||
"secondary": "#4e607b"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background-color: #f8faf9;
|
||||
color: #2d3433;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="overflow-hidden">
|
||||
<!-- SIDE NAV BAR -->
|
||||
<aside class="h-screen w-64 fixed left-0 top-0 bg-stone-50 dark:bg-stone-950 flex flex-col h-full py-8 px-6 font-manrope text-stone-800 dark:text-stone-200">
|
||||
<div class="mb-10 flex flex-col gap-1">
|
||||
<h1 class="text-xl font-bold tracking-tight text-emerald-900 dark:text-emerald-200">Serene Path</h1>
|
||||
<p class="text-xs text-stone-500 font-label">The Curated Breath</p>
|
||||
</div>
|
||||
<button class="mb-8 w-full py-3 px-4 rounded-full bg-primary text-on-primary font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all">
|
||||
<span class="material-symbols-outlined" data-icon="add">add</span>
|
||||
New Entry
|
||||
</button>
|
||||
<nav class="flex-1 flex flex-col gap-2">
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
|
||||
Calendar
|
||||
</a>
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-emerald-800 dark:text-emerald-400 font-bold border-r-4 border-emerald-700 bg-stone-100 dark:bg-stone-900" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="smart_toy">smart_toy</span>
|
||||
AI Assistant
|
||||
</a>
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="auto_awesome_motion">auto_awesome_motion</span>
|
||||
Habits
|
||||
</a>
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="library_books">library_books</span>
|
||||
Library
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto flex flex-col gap-2 pt-6 border-t border-stone-200/50 dark:border-stone-800/50">
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 transition-colors" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
Settings
|
||||
</a>
|
||||
<a class="flex items-center gap-4 py-3 px-4 rounded-xl text-stone-500 dark:text-stone-400 hover:text-emerald-700 dark:hover:text-emerald-300 transition-colors" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="help">help</span>
|
||||
Support
|
||||
</a>
|
||||
<div class="mt-4 flex items-center gap-3 px-4">
|
||||
<img alt="User profile" class="w-8 h-8 rounded-full object-cover" data-alt="Close up portrait of a calm woman with natural look in soft daylight, minimalist and peaceful aesthetic" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAR0tdnMdZsJEqW3xObOpP8RaXycu3OrWKpSl-3j6PJkhJA4U6BxpyAi0iOSkRCwhaRudEmhQ58jmdOAKu3oz9WnlxTvBNqQWv1Y-FCS9WqJsgd-xuB5OdS9ZOpUJfCyecu_tQW6q4eack9Nqs6u8rz4CC4-UtpEheRp7zBVkdu6FcyQALU1XqYlAFapOho5xLUQLtXKeKJq1ZAtx16ga1L1vjIP8VhyE5ZvB_QJKhfPJpGNqGc8dw4Ifl7UlI51nFDZEq39HFUE9z6"/>
|
||||
<span class="text-sm font-medium">Elena Thorne</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- MAIN CONTENT AREA -->
|
||||
<main class="ml-64 flex h-screen bg-surface">
|
||||
<!-- CENTER CHAT INTERFACE -->
|
||||
<div class="flex-1 flex flex-col h-full bg-surface-container-low">
|
||||
<!-- Top App Bar -->
|
||||
<header class="flex items-center justify-between px-12 py-4 bg-transparent sticky top-0 z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-container flex items-center justify-center text-primary">
|
||||
<span class="material-symbols-outlined" data-icon="smart_toy">smart_toy</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-on-surface">AI Assistant</h2>
|
||||
<span class="text-xs font-label text-on-surface-variant flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span> Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 text-on-surface-variant hover:text-primary transition-all">
|
||||
<span class="material-symbols-outlined" data-icon="notifications">notifications</span>
|
||||
</button>
|
||||
<button class="p-2 text-on-surface-variant hover:text-primary transition-all">
|
||||
<span class="material-symbols-outlined" data-icon="account_circle">account_circle</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Message Area -->
|
||||
<div class="flex-1 overflow-y-auto px-12 py-8 space-y-8">
|
||||
<!-- AI Message -->
|
||||
<div class="flex items-start gap-4 max-w-2xl">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary-container flex items-center justify-center text-primary shrink-0 mt-1">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="smart_toy">smart_toy</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="p-5 bg-surface-container-lowest rounded-2xl rounded-tl-none shadow-sm">
|
||||
<p class="text-body-lg text-on-surface leading-relaxed">
|
||||
Good morning, Elena. I've analyzed your sleep patterns and today's calendar. You have a few open windows this afternoon. Shall we plan a focused reflection session or a nature walk to keep your serenity score high?
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-label text-outline uppercase tracking-wider ml-1">Assistant • 09:12 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Message -->
|
||||
<div class="flex items-start gap-4 max-w-2xl ml-auto flex-row-reverse">
|
||||
<div class="w-8 h-8 rounded-lg overflow-hidden shrink-0 mt-1">
|
||||
<img alt="Elena" class="w-full h-full object-cover" data-alt="Close up portrait of a calm woman with natural look in soft daylight, minimalist and peaceful aesthetic" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAil22lpK92TzEl-VXOypx4-IEON39DZ5X3naTyQHbq3UtnMCokMmvk4hOw73y9rDdho0BG6JaSCm9XB_2zWaEafPrKnNmDz9GBGpiTexrkJWAGzebVlRdP6Rh1tl9NO2_ROHAdME8FVdfQ8DuKsQ0GVbick2-wZO1nIt0Y2sKlbdliOd_jBtHcHZF7WV7go-d6uqsWm78Xs9KjAnGGTn_Lt1LuHCI3gkqBnOj4JpIvVNWVOzLv1T4vV3saSA6gdyBNJpK_xf3CRiuZ"/>
|
||||
</div>
|
||||
<div class="space-y-2 flex flex-col items-end">
|
||||
<div class="p-5 bg-primary text-on-primary rounded-2xl rounded-tr-none shadow-md">
|
||||
<p class="text-body-lg leading-relaxed">
|
||||
I'd like the nature walk around 3:00 PM. Also, please remind me to finish my meditation log before dinner at 7:00 PM.
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-label text-outline uppercase tracking-wider mr-1">You • 09:14 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI Message with Dynamic Action -->
|
||||
<div class="flex items-start gap-4 max-w-2xl">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary-container flex items-center justify-center text-primary shrink-0 mt-1">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="smart_toy">smart_toy</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="p-5 bg-surface-container-lowest rounded-2xl rounded-tl-none shadow-sm border border-primary/5">
|
||||
<p class="text-body-lg text-on-surface leading-relaxed">
|
||||
Perfect choice. I've updated your schedule for today.
|
||||
</p>
|
||||
<div class="mt-4 p-4 bg-surface-container-high rounded-xl flex items-center gap-4">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="check_circle">check_circle</span>
|
||||
<div>
|
||||
<p class="text-sm font-bold">Action Recorded</p>
|
||||
<p class="text-xs text-on-surface-variant">Nature Walk scheduled for 15:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-label text-outline uppercase tracking-wider ml-1">Assistant • 09:15 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input Bar Area -->
|
||||
<div class="px-12 py-8 bg-gradient-to-t from-surface-container-low to-transparent">
|
||||
<div class="max-w-4xl mx-auto flex items-center gap-4 bg-surface-container-lowest p-3 rounded-full shadow-lg shadow-on-surface/5 border border-outline-variant/10">
|
||||
<button class="p-2 text-outline hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined" data-icon="add_circle">add_circle</span>
|
||||
</button>
|
||||
<input class="flex-1 bg-transparent border-none focus:ring-0 text-on-surface placeholder:text-outline/60 font-body" placeholder="Type a message or ask for advice..." type="text"/>
|
||||
<button class="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-on-primary hover:opacity-90 transition-all">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="send" data-weight="fill">send</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-6">
|
||||
<button class="text-[10px] font-label font-bold text-primary flex items-center gap-1 uppercase tracking-widest hover:opacity-70">
|
||||
<span class="material-symbols-outlined text-xs" data-icon="psychology">psychology</span>
|
||||
Summarize Day
|
||||
</button>
|
||||
<button class="text-[10px] font-label font-bold text-primary flex items-center gap-1 uppercase tracking-widest hover:opacity-70">
|
||||
<span class="material-symbols-outlined text-xs" data-icon="eco">eco</span>
|
||||
Mindfulness Tips
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RIGHT SIDEBAR: PLAN SUMMARY -->
|
||||
<aside class="w-80 h-full bg-surface-container border-l border-outline-variant/10 p-8 flex flex-col gap-8 overflow-y-auto">
|
||||
<div>
|
||||
<h3 class="text-headline-sm font-bold text-on-surface mb-6">Plan Summary</h3>
|
||||
<div class="space-y-6">
|
||||
<!-- Date Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-label text-outline uppercase tracking-widest">Today</span>
|
||||
<span class="text-lg font-bold text-on-surface">Friday, May 24</span>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full border-2 border-primary-container flex items-center justify-center">
|
||||
<span class="text-xs font-bold text-primary">65%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tasks Timeline -->
|
||||
<div class="relative space-y-4 pt-4">
|
||||
<div class="absolute left-4 top-0 bottom-0 w-px bg-outline-variant/30"></div>
|
||||
<!-- Task Item -->
|
||||
<div class="relative pl-10">
|
||||
<div class="absolute left-3 top-2 w-2 h-2 rounded-full bg-primary ring-4 ring-surface-container"></div>
|
||||
<div class="p-4 bg-surface-container-lowest rounded-xl shadow-sm">
|
||||
<span class="text-[10px] font-label text-primary font-bold">10:00 AM</span>
|
||||
<p class="text-sm font-bold text-on-surface mt-1">Project Sync</p>
|
||||
<p class="text-xs text-on-surface-variant">Focus on core deliverables</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scheduled Task Item -->
|
||||
<div class="relative pl-10">
|
||||
<div class="absolute left-3 top-2 w-2 h-2 rounded-full bg-secondary-dim ring-4 ring-surface-container"></div>
|
||||
<div class="p-4 bg-secondary-container/30 rounded-xl border border-secondary-container/50">
|
||||
<span class="text-[10px] font-label text-secondary font-bold">03:00 PM</span>
|
||||
<p class="text-sm font-bold text-on-secondary-container mt-1">Nature Walk</p>
|
||||
<p class="text-xs text-on-secondary-container/70">Reflect on weekly progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reminder Item -->
|
||||
<div class="relative pl-10">
|
||||
<div class="absolute left-3 top-2 w-2 h-2 rounded-full bg-tertiary-dim ring-4 ring-surface-container"></div>
|
||||
<div class="p-4 bg-tertiary-container/30 rounded-xl">
|
||||
<span class="text-[10px] font-label text-tertiary font-bold">07:00 PM</span>
|
||||
<p class="text-sm font-bold text-on-tertiary-container mt-1">Meditation Log</p>
|
||||
<p class="text-xs text-on-tertiary-container/70">Complete reflection journal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Health/Wellness Widget -->
|
||||
<div class="mt-auto">
|
||||
<div class="p-6 bg-gradient-to-br from-primary to-primary-dim rounded-2xl text-on-primary">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="material-symbols-outlined" data-icon="self_improvement">self_improvement</span>
|
||||
<span class="text-[10px] font-label font-bold uppercase tracking-widest opacity-80">Serenity Index</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<span class="text-3xl font-extrabold leading-none">8.4</span>
|
||||
<span class="text-xs font-label opacity-70 mb-1">/ 10</span>
|
||||
</div>
|
||||
<p class="text-xs mt-4 opacity-90 leading-relaxed font-body">
|
||||
"Each breath is a new beginning." You're doing great today, Elena.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-between text-on-surface-variant px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="schedule">schedule</span>
|
||||
<span class="text-xs font-label">Sync 2m ago</span>
|
||||
</div>
|
||||
<button class="text-xs font-label font-bold text-primary hover:underline">Edit Plan</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=Plus+Jakarta+Sans:wght@200..800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"on-primary-container": "#3d574d",
|
||||
"on-error-container": "#6e0a12",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"surface": "#f8faf9",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"surface-variant": "#dde4e3",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"primary-dim": "#3e594e",
|
||||
"primary": "#4a655a",
|
||||
"on-primary": "#e5fff2",
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"on-error": "#fff7f6",
|
||||
"background": "#f8faf9",
|
||||
"error": "#a83836",
|
||||
"outline-variant": "#acb3b2",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"primary-container": "#cbe9db",
|
||||
"tertiary-dim": "#594f63",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"error-container": "#fa746f",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"surface-container": "#eaefee",
|
||||
"primary-fixed": "#cbe9db",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"error-dim": "#67040d",
|
||||
"on-secondary-container": "#40536d",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"tertiary": "#655b6f",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"outline": "#757c7b",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"on-background": "#2d3433",
|
||||
"secondary-dim": "#42546f",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-surface-variant": "#596060",
|
||||
"secondary": "#4e607b"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Manrope', sans-serif; background-color: #f8faf9; color: #2d3433; }
|
||||
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
|
||||
.calendar-grid { grid-template-columns: 80px repeat(7, 1fr); }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background overflow-hidden">
|
||||
<!-- Shared SideNavBar -->
|
||||
<aside class="h-screen w-64 fixed left-0 top-0 bg-stone-50 dark:bg-stone-950 flex flex-col py-8 px-6 font-manrope text-stone-800 dark:text-stone-200">
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<img alt="User profile" class="w-10 h-10 rounded-full object-cover" data-alt="Close-up portrait of a woman in soft natural lighting, minimal aesthetic, serene expression" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAGbn0o4qJqoKdsK0dDVFGY7TZ1WDmYrE8gkUpb7PG0hBvf41AX8BAQGScbG7RKgMvxbic2RTaGRAZl0ITsTRdsw1AtenE2CVHtZyDfzn9U0PqefZ7m_HEvu7b44-N95aY9NpDsRctqvmcqEoP9ccPFXwj-gFaAWNneB8PsQPHQ1jfm8iiSDIwa2wZabnEfiNs6kF02TUixrZFLh1KxqGHg9iBGKkLUwkdFiF4AsOY0xDein1h1vqf-olMcwyvAlX0x7Uoe_NsxOtrP"/>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight text-emerald-900 dark:text-emerald-200">Serene Path</h1>
|
||||
<p class="text-xs font-label text-stone-500 uppercase tracking-widest">The Curated Breath</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full bg-primary py-3 rounded-full text-on-primary font-bold flex items-center justify-center gap-2 hover:opacity-80 transition-all active:scale-95">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
New Entry
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-2">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 text-stone-500 dark:text-stone-400" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 text-emerald-800 dark:text-emerald-400 font-bold border-r-4 border-emerald-700 bg-stone-100 dark:bg-stone-900" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
|
||||
Calendar
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 text-stone-500 dark:text-stone-400" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="smart_toy">smart_toy</span>
|
||||
AI Assistant
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 text-stone-500 dark:text-stone-400" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="auto_awesome_motion">auto_awesome_motion</span>
|
||||
Habits
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 dark:hover:bg-stone-800/50 text-stone-500 dark:text-stone-400" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="library_books">library_books</span>
|
||||
Library
|
||||
</a>
|
||||
</nav>
|
||||
<footer class="mt-auto space-y-1">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 text-stone-500" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
Settings
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl transition-colors duration-300 hover:bg-stone-200/50 text-stone-500" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="help">help</span>
|
||||
Support
|
||||
</a>
|
||||
</footer>
|
||||
</aside>
|
||||
<!-- Main Workspace -->
|
||||
<main class="ml-64 flex min-h-screen">
|
||||
<!-- Weekly Calendar Center -->
|
||||
<section class="flex-1 flex flex-col bg-surface overflow-hidden">
|
||||
<!-- Top Nav Bar Shared -->
|
||||
<header class="w-full h-16 flex items-center justify-between px-12 sticky top-0 z-10 bg-transparent font-manrope">
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 hover:bg-surface-container-low rounded-full transition-all">
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<h2 class="text-xl font-bold tracking-tight text-on-surface">September 11 — 17</h2>
|
||||
<button class="p-2 hover:bg-surface-container-low rounded-full transition-all">
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex bg-surface-container-low p-1 rounded-full">
|
||||
<button class="px-4 py-1 text-sm font-label text-on-surface-variant hover:text-on-surface">Day</button>
|
||||
<button class="px-6 py-1 text-sm font-label bg-surface-container-lowest text-primary font-bold rounded-full shadow-sm">Week</button>
|
||||
<button class="px-4 py-1 text-sm font-label text-on-surface-variant hover:text-on-surface">Month</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative group">
|
||||
<div class="flex items-center bg-surface-container-low rounded-full px-4 py-2 w-64">
|
||||
<span class="material-symbols-outlined text-outline text-sm">search</span>
|
||||
<input class="bg-transparent border-none focus:ring-0 text-sm font-label ml-2 w-full" placeholder="Search focus moments..." type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 text-on-secondary-container hover:text-emerald-600 transition-all">
|
||||
<span class="material-symbols-outlined">notifications</span>
|
||||
</button>
|
||||
<button class="p-2 text-on-secondary-container hover:text-emerald-600 transition-all">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Calendar Canvas -->
|
||||
<div class="flex-1 px-8 pb-8 overflow-y-auto no-scrollbar">
|
||||
<div class="bg-surface-container-lowest rounded-[2rem] h-full shadow-sm border border-outline-variant/10 flex flex-col">
|
||||
<!-- Day Headers -->
|
||||
<div class="calendar-grid grid border-b border-surface-container-low">
|
||||
<div class="p-4"></div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">MON</span>
|
||||
<span class="text-xl font-headline font-bold">11</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low bg-primary-container/10">
|
||||
<span class="block text-xs font-label text-primary font-bold mb-1">TUE</span>
|
||||
<span class="text-xl font-headline font-bold text-primary">12</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">WED</span>
|
||||
<span class="text-xl font-headline font-bold">13</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">THU</span>
|
||||
<span class="text-xl font-headline font-bold">14</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">FRI</span>
|
||||
<span class="text-xl font-headline font-bold">15</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">SAT</span>
|
||||
<span class="text-xl font-headline font-bold">16</span>
|
||||
</div>
|
||||
<div class="p-4 text-center border-l border-surface-container-low">
|
||||
<span class="block text-xs font-label text-on-surface-variant mb-1">SUN</span>
|
||||
<span class="text-xl font-headline font-bold text-on-surface-variant">17</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Time Grid -->
|
||||
<div class="flex-1 relative overflow-y-auto no-scrollbar">
|
||||
<div class="calendar-grid grid min-h-max">
|
||||
<!-- Time Column -->
|
||||
<div class="bg-surface-container-low/30">
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter">08:00 AM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter">09:00 AM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">10:00 AM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">11:00 AM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">12:00 PM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">01:00 PM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">02:00 PM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">03:00 PM</div>
|
||||
<div class="h-20 flex items-start justify-end pr-4 pt-2 text-[10px] font-label text-outline uppercase tracking-tighter border-t border-surface-container-low">04:00 PM</div>
|
||||
</div>
|
||||
<!-- Day Columns -->
|
||||
<div class="relative border-l border-surface-container-low"><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div></div>
|
||||
<div class="relative border-l border-surface-container-low bg-primary-container/5">
|
||||
<!-- Calendar Block: Focus -->
|
||||
<div class="absolute top-[80px] left-1 right-1 h-[140px] bg-primary text-on-primary p-3 rounded-xl shadow-sm z-20">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="material-symbols-outlined text-[14px]">psychology</span>
|
||||
<p class="text-[10px] font-label uppercase font-bold tracking-widest opacity-80">Deep Work</p>
|
||||
</div>
|
||||
<p class="text-sm font-bold leading-tight">UI Design Language</p>
|
||||
<p class="text-[10px] mt-1 opacity-70">Focus Zone</p>
|
||||
</div>
|
||||
<div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div>
|
||||
</div>
|
||||
<div class="relative border-l border-surface-container-low">
|
||||
<!-- Calendar Block: Wellness -->
|
||||
<div class="absolute top-[240px] left-1 right-1 h-[60px] bg-tertiary-container text-on-tertiary-container p-2 rounded-xl border border-tertiary/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[14px]">spa</span>
|
||||
<p class="text-xs font-bold">Guided Meditation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div>
|
||||
</div>
|
||||
<div class="relative border-l border-surface-container-low">
|
||||
<!-- Calendar Block: Collaboration -->
|
||||
<div class="absolute top-[160px] left-1 right-1 h-[80px] bg-secondary-container text-on-secondary-container p-3 rounded-xl border border-secondary/10">
|
||||
<p class="text-xs font-bold">Stakeholder Review</p>
|
||||
<p class="text-[10px] mt-1 opacity-80">Design Sync</p>
|
||||
</div>
|
||||
<div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div>
|
||||
</div>
|
||||
<div class="relative border-l border-surface-container-low"><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div></div>
|
||||
<div class="relative border-l border-surface-container-low bg-surface-container-low/20"><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div></div>
|
||||
<div class="relative border-l border-surface-container-low bg-surface-container-low/20"><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div><div class="h-20 border-b border-surface-container-low/50"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Right Context Sidebar -->
|
||||
<aside class="w-80 bg-surface-container-low p-6 flex flex-col gap-8 overflow-y-auto no-scrollbar">
|
||||
<!-- Mini Month View -->
|
||||
<div class="bg-surface-container-lowest p-6 rounded-3xl shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-bold font-headline">September 2023</h3>
|
||||
<div class="flex gap-1">
|
||||
<span class="material-symbols-outlined text-sm cursor-pointer">chevron_left</span>
|
||||
<span class="material-symbols-outlined text-sm cursor-pointer">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-y-2 text-center">
|
||||
<span class="text-[10px] font-label text-outline">S</span>
|
||||
<span class="text-[10px] font-label text-outline">M</span>
|
||||
<span class="text-[10px] font-label text-outline">T</span>
|
||||
<span class="text-[10px] font-label text-outline">W</span>
|
||||
<span class="text-[10px] font-label text-outline">T</span>
|
||||
<span class="text-[10px] font-label text-outline">F</span>
|
||||
<span class="text-[10px] font-label text-outline">S</span>
|
||||
<span class="text-xs text-outline py-1">27</span>
|
||||
<span class="text-xs text-outline py-1">28</span>
|
||||
<span class="text-xs text-outline py-1">29</span>
|
||||
<span class="text-xs text-outline py-1">30</span>
|
||||
<span class="text-xs text-outline py-1">31</span>
|
||||
<span class="text-xs py-1">1</span>
|
||||
<span class="text-xs py-1">2</span>
|
||||
<span class="text-xs py-1">3</span>
|
||||
<span class="text-xs py-1">4</span>
|
||||
<span class="text-xs py-1">5</span>
|
||||
<span class="text-xs py-1">6</span>
|
||||
<span class="text-xs py-1">7</span>
|
||||
<span class="text-xs py-1">8</span>
|
||||
<span class="text-xs py-1">9</span>
|
||||
<span class="text-xs py-1">10</span>
|
||||
<span class="text-xs py-1 bg-primary text-on-primary rounded-full font-bold">11</span>
|
||||
<span class="text-xs py-1">12</span>
|
||||
<span class="text-xs py-1">13</span>
|
||||
<span class="text-xs py-1">14</span>
|
||||
<span class="text-xs py-1">15</span>
|
||||
<span class="text-xs py-1">16</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Health Data Integration -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold font-headline uppercase tracking-wider text-on-surface-variant">Vitals Sync</h3>
|
||||
<span class="material-symbols-outlined text-emerald-600 text-sm">sync</span>
|
||||
</div>
|
||||
<!-- Sleep Card -->
|
||||
<div class="bg-surface-container-lowest p-5 rounded-3xl border-l-4 border-secondary shadow-sm">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-8 h-8 rounded-full bg-secondary-container flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-secondary text-sm">bedtime</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-label text-outline uppercase">Sleep Score</p>
|
||||
<p class="text-lg font-bold font-headline">84/100</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-surface-container-high rounded-full overflow-hidden">
|
||||
<div class="w-[84%] h-full bg-secondary"></div>
|
||||
</div>
|
||||
<p class="text-[10px] text-on-surface-variant mt-2 font-label">Recommended bedtime: 10:30 PM</p>
|
||||
</div>
|
||||
<!-- Activity Card -->
|
||||
<div class="bg-surface-container-lowest p-5 rounded-3xl border-l-4 border-primary shadow-sm">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary-container flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-primary text-sm">directions_run</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-label text-outline uppercase">Movement</p>
|
||||
<p class="text-lg font-bold font-headline">6,240 <span class="text-xs font-normal opacity-60">steps</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-surface-container-high rounded-full overflow-hidden">
|
||||
<div class="w-[62%] h-full bg-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Categories / Tags -->
|
||||
<div class="mt-auto">
|
||||
<h3 class="text-xs font-bold font-label uppercase tracking-widest text-outline mb-4">Focus Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 bg-primary/10 text-primary text-[10px] font-bold rounded-full border border-primary/20">DEEP WORK</span>
|
||||
<span class="px-3 py-1 bg-secondary/10 text-secondary text-[10px] font-bold rounded-full border border-secondary/20">STRATEGY</span>
|
||||
<span class="px-3 py-1 bg-tertiary/10 text-tertiary text-[10px] font-bold rounded-full border border-tertiary/20">RECOVERY</span>
|
||||
<span class="px-3 py-1 bg-surface-container-highest text-on-surface-variant text-[10px] font-bold rounded-full">ADMIN</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Insight Quote -->
|
||||
<div class="mt-4 p-4 rounded-3xl bg-gradient-to-br from-primary-container/30 to-tertiary-container/30">
|
||||
<p class="text-xs italic text-on-primary-container/80 leading-relaxed">
|
||||
"The way you spend your hours is the way you spend your life."
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path | Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"on-primary-container": "#3d574d",
|
||||
"on-error-container": "#6e0a12",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"surface": "#f8faf9",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"surface-variant": "#dde4e3",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"primary-dim": "#3e594e",
|
||||
"primary": "#4a655a",
|
||||
"on-primary": "#e5fff2",
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"on-error": "#fff7f6",
|
||||
"background": "#f8faf9",
|
||||
"error": "#a83836",
|
||||
"outline-variant": "#acb3b2",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"primary-container": "#cbe9db",
|
||||
"tertiary-dim": "#594f63",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"error-container": "#fa746f",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"surface-container": "#eaefee",
|
||||
"primary-fixed": "#cbe9db",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"error-dim": "#67040d",
|
||||
"on-secondary-container": "#40536d",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"tertiary": "#655b6f",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"outline": "#757c7b",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"on-background": "#2d3433",
|
||||
"secondary-dim": "#42546f",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-surface-variant": "#596060",
|
||||
"secondary": "#4e607b"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"2xl": "1.5rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.glass-panel {
|
||||
background: rgba(248, 250, 249, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body overflow-hidden">
|
||||
<!-- SideNavBar -->
|
||||
<aside class="h-screen w-64 fixed left-0 top-0 bg-stone-50 flex flex-col py-8 px-6 font-manrope text-stone-800 z-20">
|
||||
<div class="mb-10 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary flex items-center justify-center text-on-primary">
|
||||
<span class="material-symbols-outlined">spa</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight text-emerald-900">Serene Path</h1>
|
||||
<p class="text-[10px] font-label uppercase tracking-widest text-stone-500">The Curated Breath</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mb-8 w-full py-3 px-4 rounded-full bg-primary text-on-primary flex items-center justify-center gap-2 font-semibold shadow-xl shadow-primary/10 hover:opacity-90 transition-all active:scale-95">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
New Entry
|
||||
</button>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<!-- Active State: Dashboard -->
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-emerald-800 font-bold border-r-4 border-emerald-700 bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">dashboard</span>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">calendar_today</span>
|
||||
<span>Calendar</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">smart_toy</span>
|
||||
<span>AI Assistant</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">auto_awesome_motion</span>
|
||||
<span>Habits</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">library_books</span>
|
||||
<span>Library</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto space-y-1 pt-6">
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">settings</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:text-emerald-700 hover:bg-stone-200/50 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined">help</span>
|
||||
<span>Support</span>
|
||||
</a>
|
||||
<div class="pt-6 mt-6 border-t border-stone-200 flex items-center gap-3">
|
||||
<img alt="User profile" class="w-10 h-10 rounded-full object-cover border-2 border-primary-container" data-alt="portrait of a calm professional woman in minimalist studio lighting with soft natural shadows" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDQQNVeWqTZ7WCzww1HpSw6l-0NNBRLiUbydzCC5CTty-V-sVlANbx1tGCU9-bnWKvSud6PzBZc8qNg3Y3kjpD8hbB1R7YGq06sZ9YVXmkF9j6tybOTGKXyWJdTs27o-j71sWJLmaT012WS8Oj5vGtiuKUmC4ic9wYcZ4k1WlyREij59AMBmXqJjJfPiJ1ea4X6KdF1-m63ShK6fJAXfZAeFM85zOQncxqto8SGUqWEzKfM2V9GqfVC6PtewSDZNvyhg_auivVy5OdQ"/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-on-surface">Elena Vance</span>
|
||||
<span class="text-[10px] font-label text-stone-500">Premium Member</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Area -->
|
||||
<main class="ml-64 flex-1 h-screen overflow-y-auto bg-surface flex">
|
||||
<!-- Center Content: Productivity Feed -->
|
||||
<div class="flex-1 px-12 py-8 overflow-y-auto">
|
||||
<!-- TopAppBar Internal Mapping -->
|
||||
<header class="flex items-center justify-between mb-12 sticky top-0 bg-surface/80 backdrop-blur-md z-10 py-4">
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-on-surface">Good Morning, Elena</h2>
|
||||
<p class="text-on-surface-variant font-label mt-1">Your path today is clear and focused.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary transition-all cursor-pointer">search</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-on-surface-variant">
|
||||
<span class="material-symbols-outlined hover:text-primary cursor-pointer">notifications</span>
|
||||
<span class="material-symbols-outlined hover:text-primary cursor-pointer">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Grid Layout -->
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Current Focus Card (Bento Style) -->
|
||||
<section class="col-span-12 lg:col-span-8 bg-surface-container-low rounded-2xl p-8 relative overflow-hidden flex flex-col justify-between min-h-[340px]">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="px-4 py-1.5 rounded-full bg-primary-container text-on-primary-fixed text-xs font-label font-semibold">Current Focus</span>
|
||||
<span class="text-xs font-label text-on-surface-variant">45 mins remaining</span>
|
||||
</div>
|
||||
<h3 class="text-4xl font-extrabold text-on-surface mb-4 leading-tight">Serene Path UI Refinement</h3>
|
||||
<p class="text-lg text-on-surface-variant max-w-md">Deep work session focused on architectural patterns and spatial hierarchy.</p>
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center gap-4 mt-8">
|
||||
<button class="bg-primary text-on-primary px-8 py-3 rounded-full font-bold flex items-center gap-2 hover:opacity-90 active:scale-95 transition-all">
|
||||
<span class="material-symbols-outlined">pause_circle</span>
|
||||
Pause Session
|
||||
</button>
|
||||
<button class="text-primary font-bold hover:underline">Complete Task</button>
|
||||
</div>
|
||||
<!-- Visual Accent -->
|
||||
<div class="absolute right-0 bottom-0 top-0 w-1/3 bg-gradient-to-l from-primary-fixed-dim/20 to-transparent pointer-events-none"></div>
|
||||
<img alt="decorative" class="absolute right-8 top-1/2 -translate-y-1/2 w-48 h-48 object-contain opacity-40 mix-blend-multiply" data-alt="minimalist abstract sculpture of a white sphere on a pedestal in a sunlit art gallery" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCr3gmjwbNL74MbEER826FpgoKx20vqr_WCjcfnBI62zgNQBYG75qaPADzZY-_S-ia7ulrmF3JQhTbtQDXJc-wrdUDzLGQEcokZQ0FY1fybn2daumbJHgjo--1Xcib_81JclVRT8u0K7IPwYYioHlNZ6LaVdoiiiGneq3G-qqRoT9B3ppdwLdBaKS8RhSzh9V3f-67Kl3ttIg3GzQYQ3KA23dGQ0K30PE4kbiWwyAJRe_EdIURq1nGV3jh9RKmdT770ifWCxkeKiMuS"/>
|
||||
</section>
|
||||
<!-- Daily Progress Widget -->
|
||||
<section class="col-span-12 lg:col-span-4 bg-surface-container-lowest rounded-2xl p-8 border border-outline-variant/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<h4 class="text-xl font-bold text-on-surface mb-6">Daily Habits</h4>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-tertiary">mindfulness</span>
|
||||
<span class="font-medium">Meditation</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-secondary">water_drop</span>
|
||||
<span class="font-medium">Hydration</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline-variant">radio_button_unchecked</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary">local_library</span>
|
||||
<span class="font-medium">Reading</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<span class="text-sm font-label font-bold">Total Progress</span>
|
||||
<span class="text-sm font-label text-primary">68%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-primary to-primary-fixed-dim" style="width: 68%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Quick Links Grid -->
|
||||
<section class="col-span-12 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col items-center justify-center text-center hover:bg-surface-container-high transition-colors group cursor-pointer">
|
||||
<div class="w-12 h-12 rounded-full bg-surface-container-lowest flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||||
<span class="material-symbols-outlined text-primary">auto_awesome</span>
|
||||
</div>
|
||||
<span class="text-sm font-bold">Inspiration</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col items-center justify-center text-center hover:bg-surface-container-high transition-colors group cursor-pointer">
|
||||
<div class="w-12 h-12 rounded-full bg-surface-container-lowest flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||||
<span class="material-symbols-outlined text-secondary">history</span>
|
||||
</div>
|
||||
<span class="text-sm font-bold">Recent Logs</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col items-center justify-center text-center hover:bg-surface-container-high transition-colors group cursor-pointer">
|
||||
<div class="w-12 h-12 rounded-full bg-surface-container-lowest flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||||
<span class="material-symbols-outlined text-tertiary">menu_book</span>
|
||||
</div>
|
||||
<span class="text-sm font-bold">Quick Journal</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col items-center justify-center text-center hover:bg-surface-container-high transition-colors group cursor-pointer">
|
||||
<div class="w-12 h-12 rounded-full bg-surface-container-lowest flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||||
<span class="material-symbols-outlined text-on-surface-variant">settings_ethernet</span>
|
||||
</div>
|
||||
<span class="text-sm font-bold">Integrations</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Content: Day at a Glance Sidebar -->
|
||||
<aside class="w-96 bg-surface-container-low px-10 py-12 flex flex-col gap-10 overflow-y-auto">
|
||||
<!-- Energy Check-in -->
|
||||
<section>
|
||||
<h4 class="text-lg font-bold text-on-surface mb-6">Energy Check-in</h4>
|
||||
<div class="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10">
|
||||
<div class="flex justify-between mb-8">
|
||||
<span class="material-symbols-outlined text-on-surface-variant opacity-50">battery_horiz_000</span>
|
||||
<span class="material-symbols-outlined text-primary">battery_charging_80</span>
|
||||
<span class="material-symbols-outlined text-on-surface-variant opacity-50">battery_full</span>
|
||||
</div>
|
||||
<input class="w-full h-1.5 bg-surface-container-high rounded-lg appearance-none cursor-pointer accent-primary" max="100" min="0" type="range" value="75"/>
|
||||
<div class="flex justify-between mt-4">
|
||||
<span class="text-[10px] font-label text-on-surface-variant">Low</span>
|
||||
<span class="text-[10px] font-label font-bold text-primary">Focused</span>
|
||||
<span class="text-[10px] font-label text-on-surface-variant">Peak</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Schedule -->
|
||||
<section class="flex-1">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h4 class="text-lg font-bold text-on-surface">Schedule</h4>
|
||||
<span class="text-xs font-label text-primary font-bold">Today</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Morning -->
|
||||
<div class="flex gap-4 relative">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-primary ring-4 ring-primary/10"></div>
|
||||
<div class="w-0.5 flex-1 bg-outline-variant/20 my-2"></div>
|
||||
</div>
|
||||
<div class="pb-8">
|
||||
<span class="text-[10px] font-label text-on-surface-variant">08:00 AM</span>
|
||||
<p class="font-bold text-on-surface">Deep Focus: Project Alpha</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Midday -->
|
||||
<div class="flex gap-4 relative">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-secondary ring-4 ring-secondary/10"></div>
|
||||
<div class="w-0.5 flex-1 bg-outline-variant/20 my-2"></div>
|
||||
</div>
|
||||
<div class="pb-8">
|
||||
<span class="text-[10px] font-label text-on-surface-variant">12:30 PM</span>
|
||||
<p class="font-bold text-on-surface">Lunch & Mindful Walk</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Afternoon (Current) -->
|
||||
<div class="flex gap-4 relative">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-primary animate-pulse ring-4 ring-primary/20"></div>
|
||||
<div class="w-0.5 flex-1 bg-outline-variant/20 my-2"></div>
|
||||
</div>
|
||||
<div class="pb-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-label text-primary font-bold">NOW</span>
|
||||
<span class="text-[10px] font-label text-on-surface-variant">02:00 PM</span>
|
||||
</div>
|
||||
<p class="font-bold text-on-surface">Design System Audit</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Evening -->
|
||||
<div class="flex gap-4 relative">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-tertiary"></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] font-label text-on-surface-variant">05:00 PM</span>
|
||||
<p class="font-bold text-on-surface">Reflection & Wrap-up</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Decorative Card -->
|
||||
<section class="mt-auto">
|
||||
<div class="bg-gradient-to-br from-tertiary to-tertiary-dim p-6 rounded-2xl text-on-tertiary">
|
||||
<span class="material-symbols-outlined mb-4">format_quote</span>
|
||||
<p class="italic text-sm leading-relaxed mb-4">"The soul usually knows what to do to heal itself. The challenge is to silence the mind."</p>
|
||||
<p class="text-[10px] font-label opacity-80">— Caroline Myss</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- FAB Suppression: On Settings/Profile/Details, hide. On Home/Dashboard, show if relevant. -->
|
||||
<button class="fixed bottom-8 right-8 w-14 h-14 rounded-full bg-primary text-on-primary shadow-2xl flex items-center justify-center hover:scale-105 active:scale-95 transition-all z-30 lg:hidden">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Serene Path | Habits & Identity</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"on-primary-container": "#3d574d",
|
||||
"on-error-container": "#6e0a12",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"on-surface": "#2d3433",
|
||||
"surface-tint": "#4a655a",
|
||||
"surface": "#f8faf9",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"surface-variant": "#dde4e3",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"primary-dim": "#3e594e",
|
||||
"primary": "#4a655a",
|
||||
"on-primary": "#e5fff2",
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"on-error": "#fff7f6",
|
||||
"background": "#f8faf9",
|
||||
"error": "#a83836",
|
||||
"outline-variant": "#acb3b2",
|
||||
"secondary-container": "#d3e3ff",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"primary-container": "#cbe9db",
|
||||
"tertiary-dim": "#594f63",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"error-container": "#fa746f",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"surface-container": "#eaefee",
|
||||
"primary-fixed": "#cbe9db",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"error-dim": "#67040d",
|
||||
"on-secondary-container": "#40536d",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"tertiary": "#655b6f",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"outline": "#757c7b",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"on-background": "#2d3433",
|
||||
"secondary-dim": "#42546f",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-dim": "#d4dbda",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-surface-variant": "#596060",
|
||||
"secondary": "#4e607b"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background font-body text-on-surface">
|
||||
<!-- SideNavBar -->
|
||||
<aside class="h-screen w-64 fixed left-0 top-0 bg-stone-50 flex flex-col py-8 px-6 font-manrope text-stone-800">
|
||||
<div class="mb-10">
|
||||
<h1 class="text-xl font-bold tracking-tight text-emerald-900">Serene Path</h1>
|
||||
<p class="text-[10px] uppercase tracking-widest text-stone-500 mt-1">The Curated Breath</p>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-2">
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-colors duration-300 transition-all active:opacity-80 active:scale-95" href="#">
|
||||
<span class="material-symbols-outlined">dashboard</span>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-colors duration-300 transition-all active:opacity-80 active:scale-95" href="#">
|
||||
<span class="material-symbols-outlined">calendar_today</span>
|
||||
<span class="font-medium">Calendar</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-colors duration-300 transition-all active:opacity-80 active:scale-95" href="#">
|
||||
<span class="material-symbols-outlined">smart_toy</span>
|
||||
<span class="font-medium">AI Assistant</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-emerald-800 font-bold border-r-4 border-emerald-700 bg-stone-200/50 transition-all active:opacity-80 active:scale-95" href="#">
|
||||
<span class="material-symbols-outlined">auto_awesome_motion</span>
|
||||
<span class="font-medium">Habits</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-colors duration-300 transition-all active:opacity-80 active:scale-95" href="#">
|
||||
<span class="material-symbols-outlined">library_books</span>
|
||||
<span class="font-medium">Library</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-8">
|
||||
<button class="w-full bg-primary text-on-primary py-4 rounded-full font-bold flex items-center justify-center gap-2 shadow-lg shadow-primary/10 hover:opacity-90 transition-all">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
New Entry
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-auto space-y-2 border-t border-stone-100 pt-6">
|
||||
<a class="flex items-center gap-3 py-2 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-all" href="#">
|
||||
<span class="material-symbols-outlined">settings</span>
|
||||
<span class="font-medium text-sm">Settings</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2 px-4 rounded-xl text-stone-500 hover:bg-stone-200/50 transition-all" href="#">
|
||||
<span class="material-symbols-outlined">help</span>
|
||||
<span class="font-medium text-sm">Support</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-3 px-4 py-4 mt-2">
|
||||
<img alt="User profile" class="w-10 h-10 rounded-full object-cover" data-alt="Close up portrait of a serene woman with natural lighting and a minimalist background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAVnnwyoVrgeaS7kEziuuiiE0cyJVfnLCVTECD43Eubqxmhdty131e0GjGvyqXKP69ecEDZ8dvj5AEXagStK65HneJZTjZSgT4s4VfHmGHcb7Xxd9JvCocKDFViC93aGBn_sLlaO1oACISJ8OdgZrKvGUK_vbCOa6Hmx9Zihje4agEikfe2cpM3xg0G3tmJ65QnW8aY5InbDMKg_RVj3ZALN-83Vino_oskJPntq_zV6aNJFRs0O4ZWHr3AHTuM4IJ6UiKLNHIqWE3o"/>
|
||||
<div class="overflow-hidden">
|
||||
<p class="text-sm font-bold text-on-surface truncate">Elena Thorne</p>
|
||||
<p class="text-[10px] text-stone-500 uppercase tracking-tighter">Pro Member</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Area -->
|
||||
<main class="ml-64 min-h-screen flex">
|
||||
<!-- Center Canvas -->
|
||||
<div class="flex-1 p-12 overflow-y-auto no-scrollbar">
|
||||
<!-- TopNavBar (Contextual) -->
|
||||
<header class="flex items-center justify-between mb-12">
|
||||
<div>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-on-surface font-headline">Habit Synthesis</h2>
|
||||
<p class="text-on-surface-variant font-label text-sm mt-1">Nurturing your daily keystone rituals.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-outline">search</span>
|
||||
<input class="bg-surface-container-low border-none rounded-full py-2 pl-10 pr-4 text-sm focus:ring-2 focus:ring-primary/20 w-64" placeholder="Search rituals..." type="text"/>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">notifications</span>
|
||||
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Keystone Habits Grid -->
|
||||
<section class="space-y-8">
|
||||
<!-- Highlight Cards: Identity Based -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="bg-gradient-to-br from-primary to-primary-dim p-8 rounded-[2rem] text-on-primary relative overflow-hidden group">
|
||||
<div class="relative z-10">
|
||||
<span class="bg-on-primary/20 text-on-primary text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full">Identity Anchor</span>
|
||||
<h3 class="text-2xl font-bold mt-4 mb-2">"I am a focused creator."</h3>
|
||||
<p class="text-on-primary/80 text-sm max-w-[240px]">Connected to Deep Work habit. You've honored this identity for 14 consecutive days.</p>
|
||||
<div class="mt-8 flex items-center gap-4">
|
||||
<div class="h-1.5 flex-1 bg-on-primary/20 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-on-primary w-[82%]"></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold font-label">82% Mastery</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined absolute -bottom-4 -right-4 text-[120px] opacity-10 group-hover:scale-110 transition-transform duration-500">draw</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-8 rounded-[2rem] relative overflow-hidden">
|
||||
<span class="bg-secondary-container text-on-secondary-container text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full font-label">Active Goal</span>
|
||||
<h3 class="text-2xl font-bold mt-4 mb-2 text-on-surface">The Mindful Athlete</h3>
|
||||
<p class="text-on-surface-variant text-sm max-w-[240px]">Integrated with Movement rituals. 4/5 weekly milestones achieved.</p>
|
||||
<div class="mt-8 flex -space-x-2">
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-primary-container flex items-center justify-center text-[10px] font-bold">M</div>
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-primary-container flex items-center justify-center text-[10px] font-bold">T</div>
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-primary-container flex items-center justify-center text-[10px] font-bold">W</div>
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-primary-container flex items-center justify-center text-[10px] font-bold">T</div>
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-surface-container-highest flex items-center justify-center text-[10px] font-bold text-outline">F</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Detailed Habit Tracking -->
|
||||
<div class="bg-surface-container-lowest p-8 rounded-[2.5rem] shadow-sm">
|
||||
<div class="flex items-center justify-between mb-10">
|
||||
<h4 class="text-xl font-bold tracking-tight">Keystone Habit Performance</h4>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-4 py-1.5 rounded-full bg-surface-container-high text-xs font-bold font-label">Weekly</button>
|
||||
<button class="px-4 py-1.5 rounded-full hover:bg-surface-container-high text-xs font-bold font-label transition-colors">Monthly</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-12">
|
||||
<!-- Habit Row 1 -->
|
||||
<div class="grid grid-cols-[1fr_2fr_1fr] items-center gap-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-tertiary-container flex items-center justify-center text-tertiary">
|
||||
<span class="material-symbols-outlined">self_improvement</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-on-surface">Morning Stillness</p>
|
||||
<p class="text-xs text-on-surface-variant font-label">15 mins • 07:00 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-end h-16 gap-2">
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[40%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[65%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[45%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[80%]"></div>
|
||||
<div class="w-full bg-primary rounded-t-lg h-[95%]"></div>
|
||||
<div class="w-full bg-primary/40 rounded-t-lg h-[60%]"></div>
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[20%]"></div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-extrabold text-on-surface">12 <span class="text-xs font-label text-on-surface-variant">Day Streak</span></p>
|
||||
<p class="text-[10px] uppercase font-bold text-primary tracking-widest">+2 from avg</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Habit Row 2 -->
|
||||
<div class="grid grid-cols-[1fr_2fr_1fr] items-center gap-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-secondary-container flex items-center justify-center text-secondary">
|
||||
<span class="material-symbols-outlined">menu_book</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-on-surface">Curated Reading</p>
|
||||
<p class="text-xs text-on-surface-variant font-label">20 pages • Afternoon</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-end h-16 gap-2">
|
||||
<div class="w-full bg-secondary-container/30 rounded-t-lg h-[90%]"></div>
|
||||
<div class="w-full bg-secondary-container/30 rounded-t-lg h-[85%]"></div>
|
||||
<div class="w-full bg-secondary-container/30 rounded-t-lg h-[95%]"></div>
|
||||
<div class="w-full bg-secondary rounded-t-lg h-[100%]"></div>
|
||||
<div class="w-full bg-secondary/40 rounded-t-lg h-[70%]"></div>
|
||||
<div class="w-full bg-secondary/20 rounded-t-lg h-[30%]"></div>
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[10%]"></div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-extrabold text-on-surface">31 <span class="text-xs font-label text-on-surface-variant">Day Streak</span></p>
|
||||
<p class="text-[10px] uppercase font-bold text-secondary tracking-widest">Milestone: +3d</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Habit Row 3 -->
|
||||
<div class="grid grid-cols-[1fr_2fr_1fr] items-center gap-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-primary-container flex items-center justify-center text-primary">
|
||||
<span class="material-symbols-outlined">forest</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-on-surface">Nature Exposure</p>
|
||||
<p class="text-xs text-on-surface-variant font-label">10 mins • Sunlight</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-end h-16 gap-2">
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[20%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[35%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[15%]"></div>
|
||||
<div class="w-full bg-primary-container/30 rounded-t-lg h-[50%]"></div>
|
||||
<div class="w-full bg-primary rounded-t-lg h-[40%]"></div>
|
||||
<div class="w-full bg-primary/40 rounded-t-lg h-[55%]"></div>
|
||||
<div class="w-full bg-surface-container-highest rounded-t-lg h-[15%]"></div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-extrabold text-on-surface">4 <span class="text-xs font-label text-on-surface-variant">Day Streak</span></p>
|
||||
<p class="text-[10px] uppercase font-bold text-tertiary tracking-widest">In Focus</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Right Side Panel: Analysis & Affirmation -->
|
||||
<aside class="w-[400px] bg-surface-container-low border-l border-outline-variant/10 p-10 flex flex-col gap-8">
|
||||
<!-- Identity Affirmation -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">auto_awesome</span>
|
||||
<h5 class="text-sm font-bold uppercase tracking-wider font-label text-tertiary">Identity Affirmation</h5>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest p-6 rounded-3xl shadow-sm border border-outline-variant/5">
|
||||
<p class="text-lg font-medium leading-relaxed italic text-on-surface">
|
||||
"Your habits are how you embody your identity. Every action is a vote for the person you wish to become."
|
||||
</p>
|
||||
<div class="mt-4 pt-4 border-t border-surface-container-high flex items-center justify-between">
|
||||
<span class="text-xs font-label text-on-surface-variant">Identity: The Conscious Architect</span>
|
||||
<span class="material-symbols-outlined text-sm text-outline">more_horiz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Burnout Analysis -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-error" style="font-variation-settings: 'FILL' 1;">monitoring</span>
|
||||
<h5 class="text-sm font-bold uppercase tracking-wider font-label text-error">Burnout Analysis</h5>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface-container-lowest p-6 rounded-3xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm font-bold">Cognitive Load</span>
|
||||
<span class="text-xs font-label bg-error-container text-on-error-container px-2 py-0.5 rounded-full">Elevated</span>
|
||||
</div>
|
||||
<div class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden">
|
||||
<div class="h-full bg-error w-[74%]"></div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-on-surface-variant leading-relaxed">
|
||||
Research suggests your late-night sessions are impacting habit consistency by <span class="text-error font-bold">14%</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-tertiary-container/30 p-6 rounded-3xl border border-tertiary/10">
|
||||
<h6 class="text-sm font-bold text-tertiary mb-2">Restorative Suggestions</h6>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex gap-3 text-xs text-on-tertiary-container items-start">
|
||||
<span class="material-symbols-outlined text-sm mt-0.5">check_circle</span>
|
||||
<span>Shift 'Curated Reading' to 30 mins before bed to lower cortisol.</span>
|
||||
</li>
|
||||
<li class="flex gap-3 text-xs text-on-tertiary-container items-start">
|
||||
<span class="material-symbols-outlined text-sm mt-0.5">check_circle</span>
|
||||
<span>Add 5-minute Non-Sleep Deep Rest (NSDR) at 2 PM.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Visual Context -->
|
||||
<div class="mt-auto rounded-3xl overflow-hidden h-48 relative">
|
||||
<img alt="Quiet desk with notebook and plant" class="w-full h-full object-cover" data-alt="A clean minimalist workspace with a notebook, a small green plant, and soft morning sunlight casting shadows" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCMV31Y42a6hLY9oVtwic2jxzWmx2tD4OdAMuWR9ykSRKDT3CMeUZuz-4ur9C3BAENKHV1HcybZ4Iq6L7fqgL9Tn_MAXY5ClZ0Lo0HHX9j0fkrgT960_ZtBzD2fAC5KrVpk8QvnP3uOd4Nh46A6i3Y4brfel3ijzrdC5BxOODdE0B-YIXoBFLjOUiYqxac7oryruCkAPdkjRHd7FMfN9j-o6NQBgSWc-zNe-eUyQ4JIj6c9JhHRczs9ZjB30OlYKMUwwBzjDu8SfVeA"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent flex items-end p-6">
|
||||
<p class="text-white text-xs font-medium">Keep your environment as intentional as your actions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Digital Sanctuary | Sprint Planner</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Plus_Jakarta_Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-tint": "#4a655a",
|
||||
"error-dim": "#67040d",
|
||||
"surface": "#f8faf9",
|
||||
"secondary-fixed-dim": "#c2d6f5",
|
||||
"error": "#a83836",
|
||||
"surface-container-high": "#e4e9e8",
|
||||
"primary-fixed": "#cbe9db",
|
||||
"secondary-dim": "#42546f",
|
||||
"on-error-container": "#6e0a12",
|
||||
"on-tertiary-container": "#5c5367",
|
||||
"on-background": "#2d3433",
|
||||
"on-surface-variant": "#596060",
|
||||
"tertiary": "#655b6f",
|
||||
"on-primary-fixed-variant": "#466156",
|
||||
"primary-dim": "#3e594e",
|
||||
"on-primary-fixed": "#2a443b",
|
||||
"error-container": "#fa746f",
|
||||
"surface-container": "#eaefee",
|
||||
"outline-variant": "#acb3b2",
|
||||
"tertiary-dim": "#594f63",
|
||||
"primary-fixed-dim": "#bedbce",
|
||||
"on-surface": "#2d3433",
|
||||
"on-primary": "#e5fff2",
|
||||
"on-secondary-fixed-variant": "#4a5d77",
|
||||
"tertiary-fixed": "#f4e6ff",
|
||||
"tertiary-container": "#f4e6ff",
|
||||
"inverse-primary": "#d7f5e7",
|
||||
"on-secondary": "#f8f8ff",
|
||||
"surface-container-low": "#f1f4f3",
|
||||
"surface-bright": "#f8faf9",
|
||||
"on-primary-container": "#3d574d",
|
||||
"background": "#f8faf9",
|
||||
"surface-dim": "#d4dbda",
|
||||
"tertiary-fixed-dim": "#e5d8f0",
|
||||
"on-tertiary": "#fef6ff",
|
||||
"inverse-surface": "#0b0f0f",
|
||||
"primary": "#4a655a",
|
||||
"primary-container": "#cbe9db",
|
||||
"on-tertiary-fixed-variant": "#675d71",
|
||||
"on-secondary-container": "#40536d",
|
||||
"surface-container-highest": "#dde4e3",
|
||||
"secondary": "#4e607b",
|
||||
"inverse-on-surface": "#9b9d9d",
|
||||
"surface-variant": "#dde4e3",
|
||||
"outline": "#757c7b",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-tertiary-fixed": "#4a4154",
|
||||
"on-error": "#fff7f6",
|
||||
"on-secondary-fixed": "#2e405a",
|
||||
"secondary-fixed": "#d3e3ff",
|
||||
"secondary-container": "#d3e3ff"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Plus Jakarta Sans"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Manrope', sans-serif; }
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.tonal-shift { background-color: #f1f4f3; }
|
||||
.glass-panel {
|
||||
background: rgba(248, 250, 249, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
.active-nav-border { border-right: 4px solid #4a655a; }
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #dde4e3; border-radius: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-background antialiased overflow-hidden">
|
||||
<!-- SideNavBar (Left Column) -->
|
||||
<aside class="h-screen w-64 fixed left-0 top-0 border-r-0 tonal-shift flex flex-col py-8 px-6 z-50">
|
||||
<div class="mb-12">
|
||||
<h1 class="text-xl font-bold tracking-tight text-primary">Digital Sanctuary</h1>
|
||||
<p class="text-xs font-label text-secondary opacity-70 tracking-wider uppercase mt-1">Stay Focused</p>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-2">
|
||||
<!-- Deep Work (Active) -->
|
||||
<a class="flex items-center gap-4 px-4 py-3 rounded-lg text-primary font-bold active-nav-border bg-surface-container-high transition-all duration-300 scale-[0.98x]" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="timer">timer</span>
|
||||
<span class="font-medium text-sm">Deep Work</span>
|
||||
</a>
|
||||
<!-- Task Batching -->
|
||||
<a class="flex items-center gap-4 px-4 py-3 rounded-lg text-secondary opacity-70 hover:bg-surface-container-high hover:opacity-100 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="layers">layers</span>
|
||||
<span class="font-medium text-sm">Task Batching</span>
|
||||
</a>
|
||||
<!-- Productivity Stats -->
|
||||
<a class="flex items-center gap-4 px-4 py-3 rounded-lg text-secondary opacity-70 hover:bg-surface-container-high hover:opacity-100 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="insights">insights</span>
|
||||
<span class="font-medium text-sm">Productivity Stats</span>
|
||||
</a>
|
||||
<!-- Settings -->
|
||||
<a class="flex items-center gap-4 px-4 py-3 rounded-lg text-secondary opacity-70 hover:bg-surface-container-high hover:opacity-100 transition-colors duration-300" href="#">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
<span class="font-medium text-sm">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto pt-8">
|
||||
<button class="w-full py-4 px-6 rounded-full bg-gradient-to-br from-primary to-primary-container text-on-primary font-bold shadow-lg shadow-primary/10 hover:opacity-90 transition-opacity">
|
||||
Start Sprint
|
||||
</button>
|
||||
<div class="mt-8 flex items-center gap-3 px-2">
|
||||
<img alt="User profile" class="w-10 h-10 rounded-full object-cover grayscale opacity-80" data-alt="Close-up portrait of a calm professional man in a minimalist office setting, soft natural lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAMRs-teYoIX1gsBikcDvjkoxnFng7xCzSQY88Mz1RqZU1ahQ14UyQ7r7_ahzmMEibEuy97mNP3M5KuPnHwU0pQVK-VFlhisOndALt0GJ-UL5_u_s_RpRWlGjPSrf-TW7V7KDNjqRuSrcYuitAnTb8k3zCvMmQE2-FVRNtRu3qhSf7kD7uA6zDyIwExLVXAqnSiwMteoIW8qNtV0UqLjW2uAo-jab1jcF5fXamGLR6WbQlRAINGn-m5VUVTZBku68aBGoq02Ax30TOW"/>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-primary">Julian V.</p>
|
||||
<p class="text-[10px] font-label text-secondary uppercase tracking-widest">Focus Mode On</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Cluster -->
|
||||
<main class="ml-64 flex min-h-screen">
|
||||
<!-- Center Column (Timer & Tasks) -->
|
||||
<section class="flex-1 px-12 py-10 overflow-y-auto max-h-screen">
|
||||
<!-- TopAppBar Internal -->
|
||||
<header class="flex justify-between items-center mb-16 h-16 sticky top-0 bg-surface/80 backdrop-blur-md z-40">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="bolt">bolt</span>
|
||||
<h2 class="text-lg font-semibold text-primary">Sprint Planner</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="relative">
|
||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-sm" data-icon="search">search</span>
|
||||
<input class="pl-10 pr-4 py-2 bg-surface-container-low rounded-full border-none focus:ring-1 focus:ring-primary/20 text-sm w-64" placeholder="Search focus logs..." type="text"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-secondary">
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-primary transition-colors" data-icon="notifications">notifications</span>
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-primary transition-colors" data-icon="account_circle">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Large Deep Work Timer -->
|
||||
<div class="flex flex-col items-center justify-center mb-20 text-center">
|
||||
<div class="relative w-80 h-80 flex items-center justify-center rounded-full border-[12px] border-surface-container-highest">
|
||||
<!-- Progress Circle Overlay -->
|
||||
<svg class="absolute inset-0 w-full h-full -rotate-90">
|
||||
<circle cx="160" cy="160" fill="none" r="148" stroke="url(#timerGradient)" stroke-dasharray="929" stroke-dashoffset="230" stroke-linecap="round" stroke-width="12"></circle>
|
||||
<defs>
|
||||
<lineargradient id="timerGradient" x1="0%" x2="100%" y1="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4a655a"></stop>
|
||||
<stop offset="100%" stop-color="#bedbce"></stop>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-7xl font-extrabold tracking-tighter text-on-background">24:59</span>
|
||||
<span class="text-xs font-label text-secondary uppercase tracking-[0.2em] mt-2">Deep Focus</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex items-center gap-6">
|
||||
<button class="p-4 rounded-full bg-surface-container-low text-secondary hover:bg-surface-container-high transition-colors">
|
||||
<span class="material-symbols-outlined" data-icon="replay">replay</span>
|
||||
</button>
|
||||
<button class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white shadow-xl shadow-primary/20 hover:scale-105 transition-transform">
|
||||
<span class="material-symbols-outlined text-4xl" data-icon="play_arrow" data-weight="fill" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
|
||||
</button>
|
||||
<button class="p-4 rounded-full bg-surface-container-low text-secondary hover:bg-surface-container-high transition-colors">
|
||||
<span class="material-symbols-outlined" data-icon="skip_next">skip_next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sprint Tasks Section -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-2xl font-bold tracking-tight text-on-background">Sprint Tasks</h3>
|
||||
<button class="text-sm font-label font-semibold text-primary flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="add">add</span>
|
||||
Batch Task
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Task Item -->
|
||||
<div class="group flex items-center gap-4 p-5 rounded-xl bg-surface-container-lowest transition-all hover:bg-surface-container-low border border-transparent hover:border-primary/5">
|
||||
<div class="w-6 h-6 rounded-md border-2 border-primary/20 flex items-center justify-center cursor-pointer group-hover:border-primary">
|
||||
<span class="material-symbols-outlined text-primary text-lg hidden group-hover:block" data-icon="check">check</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-on-background">Audit design system color tokens</p>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<span class="text-[10px] font-label text-secondary bg-surface-container-high px-2 py-0.5 rounded uppercase tracking-wider">High Energy</span>
|
||||
<span class="text-[10px] font-label text-outline uppercase tracking-wider">15 mins</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline opacity-0 group-hover:opacity-100 cursor-grab" data-icon="drag_indicator">drag_indicator</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-4 p-5 rounded-xl bg-surface-container-lowest transition-all hover:bg-surface-container-low">
|
||||
<div class="w-6 h-6 rounded-md border-2 border-primary/20 flex items-center justify-center cursor-pointer"></div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-on-background">Review feedback for navigation shell</p>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<span class="text-[10px] font-label text-secondary bg-surface-container-high px-2 py-0.5 rounded uppercase tracking-wider">Batch: Email</span>
|
||||
<span class="text-[10px] font-label text-outline uppercase tracking-wider">25 mins</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline opacity-0 group-hover:opacity-100 cursor-grab" data-icon="drag_indicator">drag_indicator</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-4 p-5 rounded-xl bg-surface-container-lowest transition-all hover:bg-surface-container-low opacity-60">
|
||||
<div class="w-6 h-6 rounded-md border-2 border-primary bg-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-on-primary text-lg" data-icon="check" data-weight="fill" style="font-variation-settings: 'FILL' 1;">check</span>
|
||||
</div>
|
||||
<div class="flex-1 line-through">
|
||||
<p class="font-medium text-on-background">Update component JSON schema</p>
|
||||
<p class="text-[10px] font-label text-outline uppercase tracking-wider mt-1">Completed at 09:45 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Right Column (Insights & Strategy) -->
|
||||
<aside class="w-80 h-screen overflow-y-auto px-8 py-10 tonal-shift">
|
||||
<!-- Daily Progress -->
|
||||
<div class="mb-12">
|
||||
<h4 class="text-sm font-label font-bold text-secondary uppercase tracking-[0.15em] mb-6">Daily Progress</h4>
|
||||
<div class="p-6 rounded-2xl bg-surface-container-lowest">
|
||||
<div class="flex justify-between items-end mb-4">
|
||||
<p class="text-3xl font-bold text-on-background">2/4</p>
|
||||
<p class="text-xs font-label text-secondary">Sprints Finished</p>
|
||||
</div>
|
||||
<div class="w-full h-3 bg-surface-container-highest rounded-full overflow-hidden">
|
||||
<div class="h-full w-1/2 bg-gradient-to-r from-primary to-primary-fixed-dim rounded-full"></div>
|
||||
</div>
|
||||
<p class="text-[11px] text-outline mt-4 leading-relaxed italic">"Slow progress is still progress. Stay breathing."</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deep Work Insights -->
|
||||
<div class="mb-12">
|
||||
<h4 class="text-sm font-label font-bold text-secondary uppercase tracking-[0.15em] mb-6">Deep Work Insights</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="p-5 rounded-2xl bg-surface-container-lowest group hover:scale-[1.02] transition-transform cursor-default">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="material-symbols-outlined text-tertiary" data-icon="flare">flare</span>
|
||||
<p class="text-xs font-bold text-on-background">Peak Focus Time</p>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-primary">09:00 — 11:30</p>
|
||||
<p class="text-[10px] text-secondary mt-1">Based on last 7 days activity</p>
|
||||
</div>
|
||||
<div class="p-5 rounded-2xl bg-surface-container-lowest group hover:scale-[1.02] transition-transform cursor-default">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="material-symbols-outlined text-secondary" data-icon="speed">speed</span>
|
||||
<p class="text-xs font-bold text-on-background">Avg. Task Completion</p>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-primary">22 mins</p>
|
||||
<p class="text-[10px] text-secondary mt-1">8% faster than previous sprint</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Focus Strategy Widget -->
|
||||
<div>
|
||||
<h4 class="text-sm font-label font-bold text-secondary uppercase tracking-[0.15em] mb-6">Focus Strategy</h4>
|
||||
<div class="relative rounded-2xl overflow-hidden aspect-[4/5] p-6 flex flex-col justify-end">
|
||||
<!-- Abstract Background Image -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-primary/90 to-transparent z-10"></div>
|
||||
<img alt="Atmospheric plant silhouette" class="w-full h-full object-cover" data-alt="Abstract silhouette of a minimalist plant against a soft morning fog with ethereal lighting and organic textures" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAr83QAjEcihyZZ1-FWloPAjOdORsmWV-VeuuXmgx3nrdUoNp49dmhbdQxpaQklhxfa71hm2OrmIS90CIqtoUb-q9tCGJL5aobe44rCbBzOp5k3b2VMqg6jNLiTumi0d2_S7As27BVcp45_ESzb-LDDxcqT38K2K88UKGFWrfOHugOem8uSui4CiyatZ0YfIKBI1Qo0wnn0qIp4GPqYw-tYmXa_EXGMRV3BlLh8kdnvRnFKqYJBJxH0Icg7hJQtbTWR6CTaeb2oz2A6"/>
|
||||
</div>
|
||||
<div class="relative z-20">
|
||||
<h5 class="text-on-primary font-bold text-lg leading-tight mb-2">Monastic Mode</h5>
|
||||
<p class="text-on-primary/80 text-xs leading-relaxed mb-4">Minimize all external sensory input. No notifications. Pure output.</p>
|
||||
<button class="text-[11px] font-label font-extrabold text-on-primary uppercase tracking-widest border border-on-primary/30 rounded-full px-4 py-2 hover:bg-on-primary hover:text-primary transition-colors">
|
||||
Apply Tactic
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- Contextual FAB (Only on Home/Dashboard, not settings/details) -->
|
||||
<button class="fixed bottom-10 right-10 w-16 h-16 rounded-full bg-primary-container text-on-primary-container shadow-2xl flex items-center justify-center hover:scale-110 transition-transform active:scale-95 group z-50">
|
||||
<span class="material-symbols-outlined text-3xl" data-icon="add">add</span>
|
||||
<span class="absolute right-20 bg-primary text-on-primary px-4 py-2 rounded-xl text-sm font-bold opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap">Quick Task</span>
|
||||
</button>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 18.12.2",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { useColorScheme } from 'react-native';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
const { transformer, resolver } = config;
|
||||
|
||||
config.transformer = {
|
||||
...transformer,
|
||||
babelTransformerPath: require.resolve("react-native-svg-transformer"),
|
||||
};
|
||||
|
||||
config.resolver = {
|
||||
...resolver,
|
||||
assetExts: [...resolver.assetExts.filter((ext) => ext !== "svg"), "wasm"],
|
||||
sourceExts: [...resolver.sourceExts, "svg", "wasm"],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "rcs-batsirai",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bildau/rn-pdf-reader": "^4.2.7",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/manrope": "^0.4.2",
|
||||
"@expo-google-fonts/newsreader": "^0.4.1",
|
||||
"@expo-google-fonts/plus-jakarta-sans": "^0.4.2",
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@supabase/supabase-js": "^2.103.1",
|
||||
"base-64": "^1.0.0",
|
||||
"deprecated-react-native-prop-types": "^5.0.0",
|
||||
"expo": "~54.0.34",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-modules-core": "~3.0.30",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"lucide-react": "^1.16.0",
|
||||
"lucide-react-native": "^1.16.0",
|
||||
"or": "^0.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-blob-util": "^0.24.7",
|
||||
"react-native-drax": "^1.1.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-pdf": "^7.0.4",
|
||||
"react-native-pdf-renderer": "^2.3.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"babel-preset-expo": "^55.0.22",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "rcs-batsirai",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/manrope": "^0.4.2",
|
||||
"@expo-google-fonts/newsreader": "^0.4.1",
|
||||
"@expo-google-fonts/plus-jakarta-sans": "^0.4.2",
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-native-community/netinfo": "^12.0.1",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@supabase/supabase-js": "^2.103.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-sqlite": "^55.0.15",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"lucide-react-native": "^0.473.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
diff --git a/node_modules/@bildau/rn-pdf-reader/lib/index.js b/node_modules/@bildau/rn-pdf-reader/lib/index.js
|
||||
index 2483bd2..c2c49a5 100644
|
||||
--- a/node_modules/@bildau/rn-pdf-reader/lib/index.js
|
||||
+++ b/node_modules/@bildau/rn-pdf-reader/lib/index.js
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { View, ActivityIndicator, Platform, StyleSheet } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
-import * as FileSystem from 'expo-file-system';
|
||||
+import * as FileSystem from 'expo-file-system/legacy';
|
||||
const { cacheDirectory, writeAsStringAsync, deleteAsync, getInfoAsync, EncodingType, } = FileSystem;
|
||||
function viewerHtml(base64, customStyle, withScroll = false, withPinchZoom = false, maximumPinchZoomScale = 5) {
|
||||
return `
|
||||
@@ -48,10 +48,14 @@ const bundleJsPath = `${cacheDirectory}bundle.js`;
|
||||
const htmlPath = `${cacheDirectory}index.html`;
|
||||
const pdfPath = `${cacheDirectory}file.pdf`;
|
||||
function writeWebViewComponentFile(container, fileName, callback) {
|
||||
+ console.log(`[PdfReader] writing component file: ${fileName}`);
|
||||
writeAsStringAsync(`${cacheDirectory}${fileName}`, container.getBundle()).then(() => {
|
||||
+ console.log(`[PdfReader] finished writing: ${fileName}`);
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
+ }).catch(err => {
|
||||
+ console.error(`[PdfReader] error writing ${fileName}:`, err);
|
||||
});
|
||||
}
|
||||
function writeWebViewComponentFiles() {
|
||||
@@ -66,15 +70,19 @@ function writeWebViewComponentFiles() {
|
||||
});
|
||||
}
|
||||
async function writeWebViewReaderFileAsync(data, customStyle, withScroll, withPinchZoom, maximumPinchZoomScale) {
|
||||
+ console.log('[PdfReader] writeWebViewReaderFileAsync start');
|
||||
writeWebViewComponentFiles();
|
||||
+ console.log('[PdfReader] checking bundle.js exists...');
|
||||
const { exists, md5 } = await getInfoAsync(bundleJsPath, { md5: true });
|
||||
const bundleContainer = require('./bundleContainer');
|
||||
if (__DEV__ || !exists || bundleContainer.getBundleMd5() !== md5) {
|
||||
+ console.log('[PdfReader] writing bundle.js...');
|
||||
await writeAsStringAsync(bundleJsPath, bundleContainer.getBundle());
|
||||
}
|
||||
+ console.log('[PdfReader] writing index.html...');
|
||||
await writeAsStringAsync(htmlPath, viewerHtml(data, customStyle, withScroll, withPinchZoom, maximumPinchZoomScale));
|
||||
-}
|
||||
-async function writePDFAsync(base64) {
|
||||
+ console.log('[PdfReader] writeWebViewReaderFileAsync finished');
|
||||
+}async function writePDFAsync(base64) {
|
||||
await writeAsStringAsync(pdfPath, base64.replace('data:application/pdf;base64,', ''), { encoding: EncodingType.Base64 });
|
||||
}
|
||||
export async function removeFilesAsync() {
|
||||
@@ -150,6 +158,7 @@ class PdfReader extends React.Component {
|
||||
data: undefined,
|
||||
renderedOnce: false,
|
||||
};
|
||||
+ this.cachedWebviewSource = null;
|
||||
this.validate = () => {
|
||||
const { onError: propOnError, source } = this.props;
|
||||
const { renderType } = this.state;
|
||||
@@ -176,33 +185,40 @@ class PdfReader extends React.Component {
|
||||
};
|
||||
this.init = async () => {
|
||||
try {
|
||||
+ console.log('[PdfReader] init start');
|
||||
const { source, customStyle, withScroll, withPinchZoom, maximumPinchZoomScale, } = this.props;
|
||||
const { renderType } = this.state;
|
||||
+ console.log('[PdfReader] renderType:', renderType);
|
||||
switch (renderType) {
|
||||
case 'GOOGLE_DRIVE_VIEWER': {
|
||||
break;
|
||||
}
|
||||
case 'URL_TO_BASE64': {
|
||||
+ console.log('[PdfReader] fetching PDF...');
|
||||
const data = await fetchPdfAsync(source);
|
||||
+ console.log('[PdfReader] writing files...');
|
||||
await writeWebViewReaderFileAsync(data, customStyle, withScroll, withPinchZoom, maximumPinchZoomScale);
|
||||
break;
|
||||
}
|
||||
case 'DIRECT_BASE64': {
|
||||
+ console.log('[PdfReader] writing files (direct base64)...');
|
||||
await writeWebViewReaderFileAsync(source.base64, customStyle, withScroll, withPinchZoom, maximumPinchZoomScale);
|
||||
break;
|
||||
}
|
||||
case 'BASE64_TO_LOCAL_PDF': {
|
||||
+ console.log('[PdfReader] writing local PDF...');
|
||||
await writePDFAsync(source.base64);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
+ console.log('[PdfReader] init finished, setting ready: true');
|
||||
this.setState({ ready: true });
|
||||
}
|
||||
catch (error) {
|
||||
alert(`Sorry, an error occurred. ${error.message}`);
|
||||
- console.error(error);
|
||||
+ console.error('[PdfReader] init error:', error);
|
||||
}
|
||||
};
|
||||
this.getRenderType = () => {
|
||||
@@ -232,23 +248,33 @@ class PdfReader extends React.Component {
|
||||
this.getWebviewSource = () => {
|
||||
const { renderType } = this.state;
|
||||
const { source: { uri, headers }, onError, } = this.props;
|
||||
+ let result;
|
||||
switch (renderType) {
|
||||
case 'GOOGLE_READER':
|
||||
- return { uri: getGoogleReaderUrl(uri) };
|
||||
+ result = { uri: getGoogleReaderUrl(uri) };
|
||||
+ break;
|
||||
case 'GOOGLE_DRIVE_VIEWER':
|
||||
- return { uri: getGoogleDriveUrl(uri) };
|
||||
+ result = { uri: getGoogleDriveUrl(uri) };
|
||||
+ break;
|
||||
case 'DIRECT_BASE64':
|
||||
case 'URL_TO_BASE64':
|
||||
- return { uri: htmlPath };
|
||||
+ result = { uri: htmlPath };
|
||||
+ break;
|
||||
case 'DIRECT_URL':
|
||||
- return { uri: uri, headers };
|
||||
+ result = { uri: uri, headers };
|
||||
+ break;
|
||||
case 'BASE64_TO_LOCAL_PDF':
|
||||
- return { uri: pdfPath };
|
||||
+ result = { uri: pdfPath };
|
||||
+ break;
|
||||
default: {
|
||||
onError('Unknown RenderType');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+ if (!this.cachedWebviewSource || this.cachedWebviewSource.uri !== result.uri) {
|
||||
+ this.cachedWebviewSource = result;
|
||||
+ }
|
||||
+ return this.cachedWebviewSource;
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
@@ -262,6 +288,7 @@ class PdfReader extends React.Component {
|
||||
if (prevProps.source.uri !== this.props.source.uri ||
|
||||
prevProps.source.base64 !== this.props.source.base64) {
|
||||
this.setState({ ready: false, renderType: this.getRenderType() });
|
||||
+ this.cachedWebviewSource = null;
|
||||
this.validate();
|
||||
this.init();
|
||||
}
|
||||
@@ -294,20 +321,33 @@ class PdfReader extends React.Component {
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
if (ready) {
|
||||
const source = this.getWebviewSource();
|
||||
+ console.log('[PdfReader] rendering WebView with source:', source);
|
||||
return (React.createElement(View, { style: [styles.container, containerStyle] },
|
||||
React.createElement(WebView, { ...{
|
||||
originWhitelist,
|
||||
onLoad: (event) => {
|
||||
- this.setState({ renderedOnce: true });
|
||||
+ console.log('[PdfReader] WebView onLoad called');
|
||||
+ if (!this.state.renderedOnce) {
|
||||
+ this.setState({ renderedOnce: true });
|
||||
+ }
|
||||
if (onLoad) {
|
||||
onLoad(event);
|
||||
}
|
||||
},
|
||||
- onLoadEnd,
|
||||
- onError,
|
||||
- onHttpError: onError,
|
||||
+ onLoadEnd: () => {
|
||||
+ console.log('[PdfReader] WebView onLoadEnd called');
|
||||
+ if (onLoadEnd) onLoadEnd();
|
||||
+ },
|
||||
+ onError: (error) => {
|
||||
+ console.error('[PdfReader] WebView onError:', error);
|
||||
+ if (onError) onError(error);
|
||||
+ },
|
||||
+ onHttpError: (error) => {
|
||||
+ console.error('[PdfReader] WebView onHttpError:', error);
|
||||
+ if (onError) onError(error);
|
||||
+ },
|
||||
style,
|
||||
- source: renderedOnce || !isAndroid ? source : undefined,
|
||||
+ source: source,
|
||||
}, allowFileAccess: isAndroid, allowFileAccessFromFileURLs: isAndroid, allowUniversalAccessFromFileURLs: isAndroid, scalesPageToFit: Platform.select({ android: false }), mixedContentMode: isAndroid ? 'always' : undefined, sharedCookiesEnabled: false, startInLoadingState: !noLoader, renderLoading: () => (noLoader ? React.createElement(View, null) : React.createElement(Loader, null)), ...webviewProps })));
|
||||
}
|
||||
return !noLoader && !ready && React.createElement(Loader, null);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
diff --git a/node_modules/@supabase/supabase-js/dist/index.cjs b/node_modules/@supabase/supabase-js/dist/index.cjs
|
||||
index 56a9c3a..c57ea9d 100644
|
||||
--- a/node_modules/@supabase/supabase-js/dist/index.cjs
|
||||
+++ b/node_modules/@supabase/supabase-js/dist/index.cjs
|
||||
@@ -624,7 +624,7 @@ var require_extract = /* @__PURE__ */ __commonJSMin(((exports) => {
|
||||
let otelModulePromise = null;
|
||||
const OTEL_PKG = "@opentelemetry/api";
|
||||
function loadOtel() {
|
||||
- if (otelModulePromise === null) otelModulePromise = Promise.resolve(`${OTEL_PKG}`).then((s) => tslib_1.__importStar(require(s))).catch(() => null);
|
||||
+ otelModulePromise = Promise.resolve(null);
|
||||
return otelModulePromise;
|
||||
}
|
||||
/**
|
||||
diff --git a/node_modules/@supabase/supabase-js/dist/index.mjs b/node_modules/@supabase/supabase-js/dist/index.mjs
|
||||
index 0b33345..fc81723 100644
|
||||
--- a/node_modules/@supabase/supabase-js/dist/index.mjs
|
||||
+++ b/node_modules/@supabase/supabase-js/dist/index.mjs
|
||||
@@ -68,7 +68,7 @@ function __awaiter(thisArg, _arguments, P, generator) {
|
||||
let otelModulePromise = null;
|
||||
const OTEL_PKG = "@opentelemetry/api";
|
||||
function loadOtel() {
|
||||
- if (otelModulePromise === null) otelModulePromise = import(/* webpackIgnore: true */ /* turbopackIgnore: true */ /* @vite-ignore */ OTEL_PKG).catch(() => null);
|
||||
+ if (otelModulePromise === null) otelModulePromise = Promise.resolve(null);
|
||||
return otelModulePromise;
|
||||
}
|
||||
/**
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import FloatingAIDock from "@/src/components/FloatingAIDock";
|
||||
import GlassBottomTabBar from "@/src/components/GlassBottomTabBar";
|
||||
import { FONTS } from "@/src/constants/Theme";
|
||||
import { TabBarVisibilityProvider } from "@/src/hooks/useAutoHideTabBar";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Tabs } from "expo-router";
|
||||
import { Calendar, LayoutDashboard, Library } from "lucide-react-native";
|
||||
import React from "react";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const screenOptions = {
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.outline,
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
} as any;
|
||||
|
||||
return (
|
||||
<TabBarVisibilityProvider>
|
||||
<Tabs
|
||||
tabBar={(props: any) => <GlassBottomTabBar {...props} />}
|
||||
screenOptions={screenOptions}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Day",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<LayoutDashboard size={24} color={color} strokeWidth={1.5} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="calendar"
|
||||
options={{
|
||||
title: "Schedule",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Calendar size={24} color={color} strokeWidth={1.5} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="library"
|
||||
options={{
|
||||
title: "Library",
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Library size={24} color={color} strokeWidth={1.5} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<FloatingAIDock />
|
||||
|
||||
{/* Extra screens still exist in the app, but are intentionally excluded from the bottom glass tab bar.
|
||||
Keep these routes reachable via menu, buttons, or a future drawer/More screen.
|
||||
*/}
|
||||
</TabBarVisibilityProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import AIScreen from '@/src/screens/AIScreen';
|
||||
|
||||
export default AIScreen;
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
import AutoHideScrollView from "@/src/components/AutoHideScrollView";
|
||||
import { FONTS, ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useAuth } from "@/src/hooks/useAuth";
|
||||
import { useData } from "@/src/hooks/useData";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { getLocalDateString } from "@/src/lib/date-utils";
|
||||
import { performMutation } from "@/src/lib/sync";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
History as HistoryIcon,
|
||||
Layout,
|
||||
Menu,
|
||||
Settings,
|
||||
BookOpen
|
||||
} from "lucide-react-native";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
type TabType = "History" | "Repository" | "Reading";
|
||||
type FilterType = "Day" | "Week" | "Month" | "Year";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "doing" | "done";
|
||||
completed_sessions: number;
|
||||
estimated_sessions: number;
|
||||
tag: string;
|
||||
updated_at: string;
|
||||
todos?: string;
|
||||
}
|
||||
|
||||
interface ScheduleBlock {
|
||||
start: string;
|
||||
end: string;
|
||||
task: string;
|
||||
type?: string;
|
||||
todos?: any[];
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ReadingLog {
|
||||
id: string;
|
||||
book_id: string;
|
||||
book_title: string;
|
||||
start_page: number;
|
||||
end_page: number;
|
||||
pages_read: number;
|
||||
duration_seconds: number;
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>("History");
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>("Month");
|
||||
|
||||
const userId = user?.id || "guest";
|
||||
const today = getLocalDateString();
|
||||
|
||||
// 1. Fetch Completed Tasks History
|
||||
const {
|
||||
data: completedTasks,
|
||||
loading: tasksLoading,
|
||||
refresh: refreshTasks,
|
||||
} = useData<Task>(
|
||||
`SELECT * FROM tasks
|
||||
WHERE (user_id = ? OR user_id IS NULL)
|
||||
AND status = 'done'
|
||||
ORDER BY updated_at DESC LIMIT 200`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
// 2. Fetch Past Schedules
|
||||
const {
|
||||
data: pastSchedules,
|
||||
loading: schedulesLoading,
|
||||
refresh: refreshSchedules,
|
||||
} = useData<{ date: string; time_blocks: string }>(
|
||||
`SELECT date, time_blocks FROM schedules
|
||||
WHERE (user_id = ? OR user_id IS NULL)
|
||||
AND date <= ?
|
||||
ORDER BY date DESC`,
|
||||
[userId, today],
|
||||
);
|
||||
|
||||
// 3. Fetch Reading History
|
||||
const {
|
||||
data: readingLogs,
|
||||
loading: readingLoading,
|
||||
refresh: refreshReading,
|
||||
} = useData<ReadingLog>(
|
||||
`SELECT rl.*, b.title as book_title
|
||||
FROM reading_logs rl
|
||||
JOIN books b ON rl.book_id = b.id
|
||||
WHERE (rl.user_id = ? OR rl.user_id IS NULL)
|
||||
ORDER BY rl.logged_at DESC LIMIT 100`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
// Filtering Logic
|
||||
const filteredHistory = useMemo(() => {
|
||||
const now = new Date();
|
||||
const allHistory = pastSchedules
|
||||
.flatMap((s) => {
|
||||
try {
|
||||
const blocks = JSON.parse(s.time_blocks) as any[];
|
||||
return blocks.map((b) => ({ ...b, date: s.date }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.filter((b) => b.type !== "break" && b.task !== "Break");
|
||||
|
||||
return allHistory.filter((item) => {
|
||||
const itemDate = new Date(item.date);
|
||||
const diffTime = now.getTime() - itemDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
|
||||
|
||||
if (activeFilter === "Day") return diffDays <= 0;
|
||||
if (activeFilter === "Week") return diffDays <= 7;
|
||||
if (activeFilter === "Month") return diffDays <= 31;
|
||||
if (activeFilter === "Year") return diffDays <= 365;
|
||||
return true;
|
||||
});
|
||||
}, [pastSchedules, activeFilter]);
|
||||
|
||||
const filteredInventory = useMemo(() => {
|
||||
const now = new Date();
|
||||
return completedTasks.filter((task) => {
|
||||
const taskDate = new Date(task.updated_at);
|
||||
const diffTime = now.getTime() - taskDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
|
||||
|
||||
if (activeFilter === "Day") return diffDays <= 0;
|
||||
if (activeFilter === "Week") return diffDays <= 7;
|
||||
if (activeFilter === "Month") return diffDays <= 31;
|
||||
if (activeFilter === "Year") return diffDays <= 365;
|
||||
return true;
|
||||
});
|
||||
}, [completedTasks, activeFilter]);
|
||||
|
||||
const filteredReading = useMemo(() => {
|
||||
const now = new Date();
|
||||
return readingLogs.filter((log) => {
|
||||
const logDate = new Date(log.logged_at);
|
||||
const diffTime = now.getTime() - logDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
|
||||
|
||||
if (activeFilter === "Day") return diffDays <= 0;
|
||||
if (activeFilter === "Week") return diffDays <= 7;
|
||||
if (activeFilter === "Month") return diffDays <= 31;
|
||||
if (activeFilter === "Year") return diffDays <= 365;
|
||||
return true;
|
||||
});
|
||||
}, [readingLogs, activeFilter]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshTasks();
|
||||
refreshSchedules();
|
||||
refreshReading();
|
||||
}, [refreshTasks, refreshSchedules, refreshReading]),
|
||||
);
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
if (mins < 1) return "< 1 min";
|
||||
return `${mins} min${mins > 1 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea} edges={["top"]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuBtn}
|
||||
onPress={() => router.push("/menu")}
|
||||
>
|
||||
<Menu size={24} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("@/assets/images/Artboard 1 logo.png")}
|
||||
style={styles.logoImage}
|
||||
tintColor={colors.primary}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.ghostBtn}
|
||||
onPress={() => router.push("/modal")}
|
||||
>
|
||||
<Settings size={20} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === "History" && styles.activeTab]}
|
||||
onPress={() => {
|
||||
setActiveTab("History");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<HistoryIcon
|
||||
size={16}
|
||||
color={activeTab === "History" ? colors.primary : colors.outline}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "History" && { color: colors.primary },
|
||||
]}
|
||||
>
|
||||
Plans
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === "Repository" && styles.activeTab]}
|
||||
onPress={() => {
|
||||
setActiveTab("Repository");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Layout
|
||||
size={16}
|
||||
color={
|
||||
activeTab === "Repository" ? colors.primary : colors.outline
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "Repository" && { color: colors.primary },
|
||||
]}
|
||||
>
|
||||
Tasks
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === "Reading" && styles.activeTab]}
|
||||
onPress={() => {
|
||||
setActiveTab("Reading");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<BookOpen
|
||||
size={16}
|
||||
color={activeTab === "Reading" ? colors.primary : colors.outline}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "Reading" && { color: colors.primary },
|
||||
]}
|
||||
>
|
||||
Reading
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<AutoHideScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.section}>
|
||||
{/* Filter Bar */}
|
||||
<View style={styles.filterRow}>
|
||||
{["Day", "Week", "Month", "Year"].map((f) => (
|
||||
<TouchableOpacity
|
||||
key={f}
|
||||
style={[
|
||||
styles.filterBtn,
|
||||
activeFilter === f && {
|
||||
backgroundColor: colors.primary + "1A",
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setActiveFilter(f as FilterType)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
activeFilter === f && {
|
||||
color: colors.primary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{f}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activeTab === "History" ? (
|
||||
<View style={styles.historyList}>
|
||||
{schedulesLoading ? (
|
||||
<ActivityIndicator
|
||||
color={colors.primary}
|
||||
style={{ marginTop: 40 }}
|
||||
/>
|
||||
) : filteredHistory.length > 0 ? (
|
||||
filteredHistory.map((block, idx) => (
|
||||
<TouchableOpacity
|
||||
key={idx}
|
||||
style={styles.historyCard}
|
||||
onPress={() => handleReuseTask(block)}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.dateBadge}>
|
||||
<Text style={styles.dateText}>
|
||||
{block.date === today ? "TODAY" : block.date}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timeRange}>
|
||||
{block.start} - {block.end}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.taskTitle}>{block.task}</Text>
|
||||
<View style={styles.cardActions}>
|
||||
<Copy size={16} color={colors.primary} />
|
||||
<Text style={styles.reuseText}>REUSE</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Clock size={40} color={colors.outlineVariant} />
|
||||
<Text style={styles.emptyText}>
|
||||
No historical data for this period.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : activeTab === "Reading" ? (
|
||||
<View style={styles.historyList}>
|
||||
{readingLoading ? (
|
||||
<ActivityIndicator
|
||||
color={colors.primary}
|
||||
style={{ marginTop: 40 }}
|
||||
/>
|
||||
) : filteredReading.length > 0 ? (
|
||||
filteredReading.map((log) => (
|
||||
<View key={log.id} style={styles.historyCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.dateBadge}>
|
||||
<Text style={styles.dateText}>{formatDate(log.logged_at)}</Text>
|
||||
</View>
|
||||
<Text style={styles.timeRange}>{formatDuration(log.duration_seconds)}</Text>
|
||||
</View>
|
||||
<View style={styles.cardBody}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.taskTitle}>{log.book_title}</Text>
|
||||
<Text style={styles.inventoryMeta}>
|
||||
Read {log.pages_read} pages (p. {log.start_page} → {log.end_page})
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<BookOpen size={40} color={colors.outlineVariant} />
|
||||
<Text style={styles.emptyText}>No reading history for this period.</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.inventoryList}>
|
||||
{tasksLoading ? (
|
||||
<ActivityIndicator
|
||||
color={colors.primary}
|
||||
style={{ marginTop: 40 }}
|
||||
/>
|
||||
) : filteredInventory.length > 0 ? (
|
||||
filteredInventory.map((task) => (
|
||||
<TouchableOpacity
|
||||
key={task.id}
|
||||
style={styles.inventoryCard}
|
||||
onPress={() => handleReuseTask(task)}
|
||||
>
|
||||
<View style={styles.inventoryMain}>
|
||||
<View
|
||||
style={[
|
||||
styles.tagBadge,
|
||||
{ backgroundColor: colors.secondaryContainer },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tagText,
|
||||
{ color: colors.onSecondaryContainer },
|
||||
]}
|
||||
>
|
||||
{task.tag || "General"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.inventoryTitle}>{task.title}</Text>
|
||||
<Text style={styles.inventoryMeta}>
|
||||
Completed{" "}
|
||||
{getLocalDateString(new Date(task.updated_at))}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.reuseIconBtn}>
|
||||
<Copy size={20} color={colors.primary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<CheckCircle2
|
||||
size={48}
|
||||
color={colors.outlineVariant}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
No completed tasks for this period.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</AutoHideScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
backgroundColor: colors.background,
|
||||
height: 60,
|
||||
},
|
||||
logoContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: -1,
|
||||
},
|
||||
logoImage: { height: 40, width: 160 },
|
||||
menuBtn: { padding: 8 },
|
||||
ghostBtn: { padding: 8 },
|
||||
tabContainer: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + "33",
|
||||
},
|
||||
tab: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
marginRight: 12,
|
||||
borderRadius: ROUNDNESS.full,
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: colors.primaryContainer,
|
||||
},
|
||||
tabText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 14,
|
||||
color: colors.outline,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
section: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginBottom: SPACING.xl,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
filterBtn: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
minWidth: 60,
|
||||
alignItems: "center",
|
||||
},
|
||||
filterText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
historyList: { gap: 16 },
|
||||
historyCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
dateBadge: {
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
dateText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
timeRange: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
},
|
||||
cardBody: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
taskTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 18,
|
||||
color: colors.onSurface,
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
cardActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: colors.primary + "1A",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
reuseText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
},
|
||||
inventoryList: { gap: 12 },
|
||||
inventoryCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
inventoryMain: { flex: 1 },
|
||||
tagBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
},
|
||||
tagText: { fontFamily: FONTS.label, fontSize: 9 },
|
||||
inventoryTitle: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
inventoryMeta: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
marginTop: 4,
|
||||
},
|
||||
reuseIconBtn: {
|
||||
padding: 12,
|
||||
backgroundColor: colors.primaryContainer,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 60,
|
||||
gap: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
color: colors.outline,
|
||||
textAlign: "center",
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,843 @@
|
|||
import AutoHideScrollView from "@/src/components/AutoHideScrollView";
|
||||
import { FONTS, ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useAuth } from "@/src/hooks/useAuth";
|
||||
import { useData } from "@/src/hooks/useData";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { performMutation } from "@/src/lib/sync";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
History,
|
||||
Menu,
|
||||
Play,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Trash2
|
||||
} from "lucide-react-native";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { getLocalDateString } from "@/src/lib/date-utils";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { colors, focusGoal, displayName } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const userId = user?.id || "guest";
|
||||
const today = getLocalDateString();
|
||||
|
||||
const { data: habitStats, loading: habitsLoading } = useData<{
|
||||
count: number;
|
||||
}>(
|
||||
"SELECT COUNT(*) as count FROM habits WHERE is_active = 1 AND (user_id = ? OR user_id IS NULL)",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const { data: logStats, loading: logsLoading } = useData<{ count: number }>(
|
||||
"SELECT COUNT(DISTINCT l.habit_id) as count FROM logs l JOIN habits h ON l.habit_id = h.id WHERE date(l.logged_at, 'localtime') = date('now', 'localtime') AND (h.user_id = ? OR h.user_id IS NULL)",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const { data: sessionStats } = useData<{ count: number }>(
|
||||
"SELECT SUM(completed_sessions) as count FROM tasks WHERE (user_id = ? OR user_id IS NULL) AND date(updated_at, 'localtime') = date('now', 'localtime')",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const {
|
||||
data: habits,
|
||||
loading: listLoading,
|
||||
refresh: refreshHabits,
|
||||
} = useData<{ id: string; title: string; is_done_today: number }>(
|
||||
`SELECT h.id, h.title,
|
||||
(SELECT COUNT(*) FROM logs l WHERE l.habit_id = h.id AND date(l.logged_at, 'localtime') = date('now', 'localtime')) as is_done_today
|
||||
FROM habits h
|
||||
WHERE h.is_active = 1 AND (h.user_id = ? OR h.user_id IS NULL) LIMIT 3`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const {
|
||||
data: shortcuts,
|
||||
loading: shortcutsLoading,
|
||||
refresh: refreshShortcuts,
|
||||
} = useData<{ id: string; title: string; url: string; icon: string }>(
|
||||
"SELECT * FROM shortcuts WHERE user_id = ? OR user_id IS NULL",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const { data: identitySettings } = useData<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'identity_anchor'",
|
||||
[],
|
||||
);
|
||||
|
||||
const { data: latestTask } = useData<{ title: string }>(
|
||||
"SELECT title FROM tasks WHERE (user_id = ? OR user_id IS NULL) AND status != 'done' ORDER BY updated_at DESC LIMIT 1",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const identityAnchor =
|
||||
identitySettings?.[0]?.value || "The Disciplined Creator";
|
||||
const currentFocus = latestTask?.[0]?.title || "Daily Discipline";
|
||||
|
||||
// Refresh data when screen is focused
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshHabits();
|
||||
refreshShortcuts();
|
||||
}, []),
|
||||
);
|
||||
|
||||
const handleToggleHabit = async (habitId: string, isDone: boolean) => {
|
||||
if (isDone) return;
|
||||
try {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await performMutation("logs", "INSERT", {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
habit_id: habitId,
|
||||
status: "completed",
|
||||
logged_at: new Date().toISOString(),
|
||||
});
|
||||
refreshHabits();
|
||||
} catch (err) {
|
||||
console.error("Failed to log habit:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const activeHabits = habitStats?.[0]?.count || 0;
|
||||
const completedToday = logStats?.[0]?.count || 0;
|
||||
const sessionsDone = sessionStats?.[0]?.count || 0;
|
||||
|
||||
const userName = useMemo(() => {
|
||||
if (displayName) return displayName;
|
||||
if (user?.email) return user.email.split("@")[0];
|
||||
return "Guest";
|
||||
}, [displayName, user]);
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}, []);
|
||||
|
||||
const isLoading =
|
||||
habitsLoading || logsLoading || listLoading || shortcutsLoading;
|
||||
|
||||
const handleOpenLink = async (url: string) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
try {
|
||||
const supported = await Linking.canOpenURL(url);
|
||||
if (supported) {
|
||||
await Linking.openURL(url);
|
||||
} else {
|
||||
Alert.alert("Error", "Don't know how to open this URL: " + url);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "An error occurred while trying to open the link");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteShortcut = async (id: string) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
Alert.alert(
|
||||
"Delete Shortcut",
|
||||
"Are you sure you want to remove this shortcut?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await performMutation("shortcuts", "DELETE", { id });
|
||||
refreshShortcuts();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to delete shortcut");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea} edges={["top"]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuBtn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/menu");
|
||||
}}
|
||||
>
|
||||
<Menu size={24} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("@/assets/images/Artboard 1 logo.png")}
|
||||
style={styles.logoImage}
|
||||
tintColor={colors.primary}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity
|
||||
style={styles.ghostBtn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/history");
|
||||
}}
|
||||
>
|
||||
<History size={20} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.ghostBtn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/modal");
|
||||
}}
|
||||
>
|
||||
<Settings size={20} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<AutoHideScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={true}
|
||||
>
|
||||
{/* Greeting */}
|
||||
<View style={styles.greetingContainer}>
|
||||
<Text style={styles.labelCaps}>DAILY OVERVIEW</Text>
|
||||
<Text style={styles.greetingText}>
|
||||
{greeting}, {userName}. Let's find your flow today.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Section */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statsGrid}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.statCard,
|
||||
{ borderLeftColor: colors.primary, borderLeftWidth: 4 },
|
||||
]}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/hh_habits");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.statLabel}>Active Habits</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{isLoading ? "..." : activeHabits}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.statCard,
|
||||
{ borderLeftColor: colors.secondary, borderLeftWidth: 4 },
|
||||
]}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/calendar");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.statLabel}>Done Today</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{isLoading ? "..." : completedToday}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Current Focus Card */}
|
||||
<View style={styles.focusContainer}>
|
||||
<View style={styles.focusCard}>
|
||||
<View style={styles.focusHeader}>
|
||||
<View style={styles.focusIconBg}>
|
||||
<Sparkles size={20} color={colors.onPrimary} />
|
||||
</View>
|
||||
<Text style={styles.focusBadge}>
|
||||
{identityAnchor.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.focusTitle}>{currentFocus}</Text>
|
||||
<Text style={styles.focusDesc}>
|
||||
Refining your presence through focused intention and consistent
|
||||
action.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.primaryBtn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
router.push("/sprint");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryBtnText}>Resume Session</Text>
|
||||
<Play size={16} color={colors.primary} fill={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Focus Capacity Slider */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>FOCUS CAPACITY</Text>
|
||||
<View style={styles.sliderContainer}>
|
||||
<View style={styles.sliderTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.sliderFill,
|
||||
{
|
||||
width: `${Math.min((sessionsDone / focusGoal) * 100, 100)}%`,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.sliderThumb,
|
||||
{
|
||||
left: `${Math.min((sessionsDone / focusGoal) * 100, 100)}%`,
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.sliderLabels}>
|
||||
<Text style={styles.sliderLevel}>
|
||||
{sessionsDone} sessions done
|
||||
</Text>
|
||||
<Text style={styles.sliderLevel}>Goal: {focusGoal}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Keystone Habits */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.rowBetween}>
|
||||
<Text style={styles.sectionTitle}>Daily Habits</Text>
|
||||
<View style={styles.rowAlign}>
|
||||
<TouchableOpacity
|
||||
style={{ marginRight: 16 }}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/add-habit");
|
||||
}}
|
||||
>
|
||||
<Plus size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/hh_habits");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.sectionSubtitle}>View All</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator
|
||||
color={colors.primary}
|
||||
style={{ marginVertical: 20 }}
|
||||
/>
|
||||
) : habits.length > 0 ? (
|
||||
<View style={styles.habitList}>
|
||||
{habits.map((habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
title={habit.title}
|
||||
done={habit.is_done_today > 0}
|
||||
colors={colors}
|
||||
onToggle={() =>
|
||||
handleToggleHabit(habit.id, habit.is_done_today > 0)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.emptyStateCard}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/add-habit");
|
||||
}}
|
||||
>
|
||||
<Plus size={24} color={colors.primary} />
|
||||
<Text style={styles.emptyStateText}>
|
||||
Create your first habit to start tracking
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Tools / Shortcuts */}
|
||||
<View style={styles.toolsContainer}>
|
||||
<View style={styles.rowBetween}>
|
||||
<Text style={styles.sectionLabel}>ECOSYSTEM TOOLS</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push("/add-shortcut");
|
||||
}}
|
||||
>
|
||||
<Plus size={16} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.toolsGrid}>
|
||||
{shortcuts.length > 0 ? (
|
||||
shortcuts.map((shortcut) => (
|
||||
<ToolCard
|
||||
key={shortcut.id}
|
||||
icon={<Globe size={20} color={colors.primary} />}
|
||||
label={shortcut.title}
|
||||
colors={colors}
|
||||
onPress={() => handleOpenLink(shortcut.url)}
|
||||
onDelete={() => handleDeleteShortcut(shortcut.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyStateContainer}>
|
||||
<Text style={styles.emptyStateTextSmall}>
|
||||
No shortcuts added yet.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</AutoHideScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HabitItem({
|
||||
title,
|
||||
done,
|
||||
colors,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
done: boolean;
|
||||
colors: any;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<TouchableOpacity style={stylesHabit.habitItem} onPress={onToggle}>
|
||||
<View
|
||||
style={[
|
||||
stylesHabit.checkbox,
|
||||
{ borderColor: colors.primary },
|
||||
done && { backgroundColor: colors.primary },
|
||||
]}
|
||||
>
|
||||
{done && <CheckCircle2 size={14} color={colors.onPrimary} />}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
stylesHabit.habitText,
|
||||
{ color: colors.onSurface },
|
||||
done && {
|
||||
color: colors.onSurfaceVariant,
|
||||
textDecorationLine: "line-through",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesHabit = StyleSheet.create({
|
||||
habitItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
checkbox: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
habitText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
function ToolCard({
|
||||
icon,
|
||||
label,
|
||||
colors,
|
||||
onPress,
|
||||
onDelete,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
colors: any;
|
||||
onPress?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={stylesTool.container}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
stylesTool.toolCard,
|
||||
{ backgroundColor: colors.surfaceVariant },
|
||||
]}
|
||||
onPress={
|
||||
onPress ||
|
||||
(() =>
|
||||
Alert.alert(
|
||||
"External Tool",
|
||||
`Launching integrated ${label} dashboard.`,
|
||||
))
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
stylesTool.iconContainer,
|
||||
{ backgroundColor: colors.surface },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
<Text
|
||||
style={[stylesTool.toolLabel, { color: colors.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{onDelete && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
stylesTool.deleteBtn,
|
||||
{ backgroundColor: colors.error + "1A" },
|
||||
]}
|
||||
onPress={onDelete}
|
||||
>
|
||||
<Trash2 size={14} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesTool = StyleSheet.create({
|
||||
container: {
|
||||
width: "47%",
|
||||
marginBottom: SPACING.md,
|
||||
position: "relative",
|
||||
},
|
||||
toolCard: {
|
||||
width: "100%",
|
||||
padding: SPACING.md,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
},
|
||||
deleteBtn: {
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
padding: 10,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
},
|
||||
toolLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
backgroundColor: colors.background,
|
||||
height: 60,
|
||||
},
|
||||
logoContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: -1,
|
||||
},
|
||||
menuBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
logoImage: {
|
||||
height: 40,
|
||||
width: 160,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
ghostBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
greetingContainer: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingBottom: SPACING.lg,
|
||||
paddingTop: SPACING.md,
|
||||
},
|
||||
labelCaps: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
color: colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
greetingText: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 28,
|
||||
color: colors.onSurface,
|
||||
lineHeight: 34,
|
||||
},
|
||||
statsContainer: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: "row",
|
||||
gap: SPACING.md,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
padding: SPACING.md,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
statLabel: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 32,
|
||||
fontFamily: FONTS.headline,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
focusContainer: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
focusCard: {
|
||||
backgroundColor: colors.primary,
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.xl,
|
||||
},
|
||||
focusHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
focusIconBg: {
|
||||
padding: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderRadius: ROUNDNESS.md,
|
||||
},
|
||||
focusBadge: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.onPrimary,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
focusTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 24,
|
||||
color: colors.onPrimary,
|
||||
marginBottom: 8,
|
||||
},
|
||||
focusDesc: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
color: colors.onPrimary,
|
||||
opacity: 0.8,
|
||||
lineHeight: 22,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.onPrimary,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: ROUNDNESS.full,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: colors.primary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 14,
|
||||
},
|
||||
section: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 24,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 13,
|
||||
color: colors.primary,
|
||||
},
|
||||
rowBetween: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
rowAlign: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
sliderContainer: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
sliderTrack: {
|
||||
height: 8,
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: 4,
|
||||
position: "relative",
|
||||
marginBottom: 12,
|
||||
},
|
||||
sliderFill: {
|
||||
height: "100%",
|
||||
borderRadius: 4,
|
||||
},
|
||||
sliderThumb: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
position: "absolute",
|
||||
top: -6,
|
||||
marginLeft: -10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
sliderLabels: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
sliderLevel: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 11,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
habitList: {
|
||||
gap: 4,
|
||||
},
|
||||
toolsContainer: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
toolsGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
emptyStateContainer: {
|
||||
width: "100%",
|
||||
padding: SPACING.lg,
|
||||
backgroundColor: colors.surfaceVariant + "4D",
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.outlineVariant,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyStateTextSmall: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
emptyStateCard: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: SPACING.xl,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.primary,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginTop: 10,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import AutoHideScrollView from "@/src/components/AutoHideScrollView";
|
||||
import { SPACING } from "@/src/constants/Theme";
|
||||
import { useAuth } from "@/src/hooks/useAuth";
|
||||
import { useData } from "@/src/hooks/useData";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { supabase } from "@/src/lib/supabase";
|
||||
import { performMutation } from "@/src/lib/sync";
|
||||
import { getDb } from "@/src/db/database";
|
||||
import { decode } from "base-64";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import { resolveFileUri, getRelativePath, getDocumentDirectory } from "@/src/lib/file-utils";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
StyleSheet,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { ROUNDNESS } from "@/src/constants/Theme";
|
||||
|
||||
// Import extracted components
|
||||
import LibraryHeader from "@/src/components/Library/LibraryHeader";
|
||||
import LibraryStats from "@/src/components/Library/LibraryStats";
|
||||
import BookGrid from "@/src/components/Library/BookGrid";
|
||||
import AddBookModal from "@/src/components/Library/AddBookModal";
|
||||
import BookDetailsModal from "@/src/components/Library/BookDetailsModal";
|
||||
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
file_uri: string;
|
||||
cover_uri: string;
|
||||
status: "reading" | "finished" | "want_to_read";
|
||||
updated_at: string;
|
||||
synthesis?: string;
|
||||
}
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const { colors, isDark } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const userId = user?.id || "guest";
|
||||
|
||||
// State
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [bookTitle, setBookTitle] = useState("");
|
||||
const [bookAuthor, setBookAuthor] = useState("");
|
||||
const [totalPages, setTotalPages] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
|
||||
// Data
|
||||
const {
|
||||
data: books,
|
||||
loading: booksLoading,
|
||||
refresh: refreshBooks,
|
||||
} = useData<Book>(
|
||||
"SELECT * FROM books WHERE (user_id = ? OR user_id IS NULL) ORDER BY updated_at DESC",
|
||||
[userId],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const reading = books.filter((b) => b.status === "reading");
|
||||
const finished = books.filter((b) => b.status === "finished");
|
||||
const totalPagesRead = books.reduce(
|
||||
(acc, b) => acc + (b.current_page || 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
reading: reading.length,
|
||||
finished: finished.length,
|
||||
pages: totalPagesRead,
|
||||
};
|
||||
}, [books]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshBooks();
|
||||
}, [refreshBooks]),
|
||||
);
|
||||
|
||||
const handlePickDocument = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ["application/pdf", "application/epub+zip"],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const file = result.assets[0];
|
||||
setSelectedFile(file);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"process-book-ai",
|
||||
{
|
||||
body: { filename: file.name },
|
||||
},
|
||||
);
|
||||
|
||||
if (!error && data) {
|
||||
setBookTitle(data.title || file.name.replace(/\.[^/.]+$/, ""));
|
||||
setBookAuthor(data.author || "");
|
||||
setTotalPages(data.totalPages?.toString() || "");
|
||||
} else {
|
||||
setBookTitle(file.name.replace(/\.[^/.]+$/, ""));
|
||||
}
|
||||
} catch (e) {
|
||||
setBookTitle(file.name.replace(/\.[^/.]+$/, ""));
|
||||
} finally {
|
||||
setShowAddModal(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert("Error", "Failed to pick document");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBook = async () => {
|
||||
if (!bookTitle.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let finalUri = "";
|
||||
if (selectedFile) {
|
||||
const fileExt = selectedFile.name.split(".").pop();
|
||||
const fileName = `${userId}/${Date.now()}.${fileExt}`;
|
||||
const filePath = `${fileName}`;
|
||||
|
||||
const docDir = getDocumentDirectory();
|
||||
const localUri = `${docDir}books/${fileName}`;
|
||||
const dirPath = `${docDir}books/${userId}`;
|
||||
await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true });
|
||||
await FileSystem.copyAsync({
|
||||
from: selectedFile.uri,
|
||||
to: localUri,
|
||||
});
|
||||
|
||||
const base64 = await FileSystem.readAsStringAsync(selectedFile.uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
supabase.storage
|
||||
.from("books")
|
||||
.upload(filePath, decode(base64), {
|
||||
contentType: selectedFile.mimeType || "application/pdf",
|
||||
upsert: true,
|
||||
})
|
||||
.then(({ error }) => {
|
||||
if (error) console.error("Cloud sync error:", error);
|
||||
});
|
||||
|
||||
finalUri = getRelativePath(localUri);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
await performMutation("books", "INSERT", {
|
||||
id,
|
||||
user_id: userId,
|
||||
title: bookTitle.trim(),
|
||||
author: bookAuthor.trim() || "Unknown",
|
||||
total_pages: parseInt(totalPages) || 0,
|
||||
current_page: 0,
|
||||
file_uri: finalUri,
|
||||
status: "reading",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setShowAddModal(false);
|
||||
setBookTitle("");
|
||||
setBookAuthor("");
|
||||
setTotalPages("");
|
||||
setSelectedFile(null);
|
||||
refreshBooks();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Alert.alert("Upload Error", "Failed to add book to library.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [selectedBookForDetails, setSelectedBookForDetails] =
|
||||
useState<Book | null>(null);
|
||||
|
||||
const openDetails = (book: Book) => {
|
||||
setSelectedBookForDetails(book);
|
||||
setShowDetailsModal(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const openReader = (book: Book) => {
|
||||
setShowDetailsModal(false);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
router.push(`/reader/${book.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteBook = async (id: string, fileUri?: string) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
Alert.alert(
|
||||
"Remove Book",
|
||||
"Are you sure you want to delete this book from your library?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await performMutation("books", "DELETE", { id });
|
||||
|
||||
if (fileUri) {
|
||||
const resolvedUri = resolveFileUri(fileUri);
|
||||
const fileInfo = await FileSystem.getInfoAsync(resolvedUri);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(resolvedUri, { idempotent: true });
|
||||
}
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
await db.runAsync("DELETE FROM reading_sessions WHERE book_id = ?", [id]);
|
||||
|
||||
refreshBooks();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Delete error:", e);
|
||||
Alert.alert("Error", "Failed to delete book");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||
|
||||
const generateAIInsights = async (book: Book) => {
|
||||
setIsGeneratingAI(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"process-book-ai",
|
||||
{
|
||||
body: { title: book.title },
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await performMutation("books", "UPDATE", {
|
||||
id: book.id,
|
||||
synthesis: data.synthesis,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
refreshBooks();
|
||||
Alert.alert(
|
||||
"AI Synthesis Complete",
|
||||
"Insights have been generated from your reading session.",
|
||||
);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Alert.alert("AI Error", "Failed to generate insights.");
|
||||
} finally {
|
||||
setIsGeneratingAI(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea} edges={["top"]}>
|
||||
<LibraryHeader onSettingsPress={() => router.push("/modal")} />
|
||||
|
||||
<AutoHideScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<LibraryStats stats={stats} />
|
||||
|
||||
<BookGrid
|
||||
books={books as any}
|
||||
loading={booksLoading}
|
||||
onBookPress={openDetails}
|
||||
onEmptyPress={handlePickDocument}
|
||||
/>
|
||||
</AutoHideScrollView>
|
||||
|
||||
<AddBookModal
|
||||
visible={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
bookTitle={bookTitle}
|
||||
onTitleChange={setBookTitle}
|
||||
bookAuthor={bookAuthor}
|
||||
onAuthorChange={setBookAuthor}
|
||||
totalPages={totalPages}
|
||||
onPagesChange={setTotalPages}
|
||||
onAddBook={handleAddBook}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<BookDetailsModal
|
||||
visible={showDetailsModal}
|
||||
onClose={() => setShowDetailsModal(false)}
|
||||
book={selectedBookForDetails as any}
|
||||
onContinueReading={() => selectedBookForDetails && openReader(selectedBookForDetails)}
|
||||
onGenerateAI={() => selectedBookForDetails && generateAIInsights(selectedBookForDetails)}
|
||||
onDelete={() => selectedBookForDetails && handleDeleteBook(selectedBookForDetails.id, selectedBookForDetails.file_uri)}
|
||||
isGeneratingAI={isGeneratingAI}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.fab, { backgroundColor: colors.primary }]}
|
||||
onPress={handlePickDocument}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Plus size={28} color={colors.onPrimary} />
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
fab: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 90,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
elevation: 8,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Manrope_700Bold, Manrope_400Regular } from '@expo-google-fonts/manrope';
|
||||
import { PlusJakartaSans_500Medium, PlusJakartaSans_700Bold } from '@expo-google-fonts/plus-jakarta-sans';
|
||||
import { initDatabase } from '@/src/db/database';
|
||||
import { useTheme, BatsirThemeProvider } from '@/src/hooks/useTheme';
|
||||
import { useSync } from '@/src/hooks/useSync';
|
||||
import { AnimatedSplashScreen } from '@/src/components/animated-splash-screen';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated';
|
||||
|
||||
// Disable strict mode for Reanimated logger to suppress warnings about reading value during render,
|
||||
// which can be triggered by the React Compiler or third-party libraries in Reanimated 4.
|
||||
configureReanimatedLogger({
|
||||
level: ReanimatedLogLevel.warn,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync().catch(() => {
|
||||
/* Reloading in development can sometimes cause this to fail, ignore it */
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
const [dbLoaded, setDbLoaded] = useState(false);
|
||||
const [fontsLoaded, fontError] = useFonts({
|
||||
Manrope_700Bold,
|
||||
Manrope_400Regular,
|
||||
PlusJakartaSans_500Medium,
|
||||
PlusJakartaSans_700Bold,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initDatabase()
|
||||
.then(() => setDbLoaded(true))
|
||||
.catch((err) => console.error('Database initialization failed:', err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fontError) throw fontError;
|
||||
}, [fontError]);
|
||||
|
||||
if (!fontsLoaded || !dbLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<BatsirThemeProvider>
|
||||
<SyncWrapper />
|
||||
<RootLayoutContent />
|
||||
</BatsirThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncWrapper() {
|
||||
useSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
function RootLayoutContent() {
|
||||
const { isLoaded, colors } = useTheme();
|
||||
const [animationFinished, setAnimationFinished] = useState(false);
|
||||
const splashHidden = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && !splashHidden.current) {
|
||||
const hideSplash = async () => {
|
||||
try {
|
||||
splashHidden.current = true;
|
||||
// Verify if we can actually hide it
|
||||
await SplashScreen.hideAsync();
|
||||
} catch (e) {
|
||||
// If it fails, it usually means it's already hidden or not registered
|
||||
console.log("Splash hide safely ignored:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(hideSplash, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isLoaded]);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RootLayoutNav />
|
||||
{!animationFinished && (
|
||||
<AnimatedSplashScreen
|
||||
onAnimationFinish={() => setAnimationFinished(true)}
|
||||
backgroundColor={colors.background}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const { colorScheme } = useTheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', headerShown: false }} />
|
||||
<Stack.Screen name="menu" options={{ presentation: 'modal', headerShown: false }} />
|
||||
<Stack.Screen name="add-habit" options={{ presentation: 'modal', headerShown: false }} />
|
||||
<Stack.Screen name="add-shortcut" options={{ presentation: 'modal', headerShown: false }} />
|
||||
<Stack.Screen name="add-task" options={{ presentation: 'modal', headerShown: false }} />
|
||||
|
||||
{/* Secondary Screens now in root stack */}
|
||||
<Stack.Screen name="(tabs)/history" options={{ headerShown: false, title: 'History' }} />
|
||||
<Stack.Screen name="(tabs)/sprint" options={{ headerShown: false, title: 'Sprint' }} />
|
||||
<Stack.Screen name="(tabs)/aa_ai" options={{ headerShown: false, title: 'Assistant' }} />
|
||||
<Stack.Screen name="(tabs)/hh_habits" options={{ headerShown: false, title: 'Habits' }} />
|
||||
</Stack>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,571 @@
|
|||
import { FONTS, ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useAuth } from "@/src/hooks/useAuth";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { useData } from "@/src/hooks/useData";
|
||||
import { performMutation } from "@/src/lib/sync";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Repeat,
|
||||
Save,
|
||||
Sparkles,
|
||||
X,
|
||||
MapPin,
|
||||
Anchor,
|
||||
ChevronDown,
|
||||
} from "lucide-react-native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { TimeInput } from "@/src/components/ui/TimeInput";
|
||||
|
||||
const FREQUENCIES = [
|
||||
{ label: "Daily", value: "daily" },
|
||||
{ label: "Weekly", value: "weekly" },
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
];
|
||||
|
||||
const TIME_PRESETS = [
|
||||
{ label: "Morning", time: "08:00" },
|
||||
{ label: "Noon", time: "12:00" },
|
||||
{ label: "Evening", time: "18:00" },
|
||||
{ label: "Night", time: "22:00" },
|
||||
];
|
||||
|
||||
export default function AddHabitScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const userId = user?.id || 'guest';
|
||||
const { data: existingHabits } = useData<{id: string, title: string}>(
|
||||
'SELECT id, title FROM habits WHERE is_active = 1 AND (user_id = ? OR user_id IS NULL)',
|
||||
[userId]
|
||||
);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [frequency, setFrequency] = useState("daily");
|
||||
const [preferredTime, setPreferredTime] = useState("08:00");
|
||||
const [location, setLocation] = useState("");
|
||||
const [twoMinuteVersion, setTwoMinuteVersion] = useState("");
|
||||
const [anchorHabitId, setAnchorHabitId] = useState<string | null>(null);
|
||||
const [weekendFlexibility, setWeekendFlexibility] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAnchorModal, setShowAnchorModal] = useState(false);
|
||||
|
||||
const selectedAnchor = useMemo(() =>
|
||||
existingHabits.find(h => h.id === anchorHabitId),
|
||||
[existingHabits, anchorHabitId]
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title) {
|
||||
Alert.alert("Error", "Please provide a title for your habit");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await performMutation("habits", "INSERT", {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
user_id: userId,
|
||||
title,
|
||||
frequency,
|
||||
preferred_time: preferredTime,
|
||||
location: location,
|
||||
two_minute_version: twoMinuteVersion,
|
||||
anchor_habit_id: anchorHabitId,
|
||||
weekend_flexibility: weekendFlexibility ? 1 : 0,
|
||||
is_active: 1,
|
||||
});
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error("Failed to save habit:", error);
|
||||
Alert.alert("Error", "Failed to save habit");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>New Habit</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<Save size={24} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>HABIT ARCHITECT</Text>
|
||||
|
||||
{/* Title */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>WHAT IS THE HABIT?</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Sparkles
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. Meditate"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Implementation Intentions: Location */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>WHERE WILL YOU DO IT? (LOCATION)</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MapPin
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. My study, the gym, on the sofa"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Small Start: Two-Minute Version */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>THE TWO-MINUTE VERSION (START SMALL)</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Sparkles
|
||||
size={20}
|
||||
color={colors.secondary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. Close my eyes for 1 minute"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={twoMinuteVersion}
|
||||
onChangeText={setTwoMinuteVersion}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.hintText}>"Optimize for the starting line, not the finish line."</Text>
|
||||
</View>
|
||||
|
||||
{/* Habit Stacking: Anchor Habit */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>STACK IT: AFTER I...</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.inputWrapper}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setShowAnchorModal(true);
|
||||
}}
|
||||
>
|
||||
<Anchor
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<Text style={[styles.input, { textAlignVertical: 'center', paddingTop: 14 }]}>
|
||||
{selectedAnchor ? selectedAnchor.title : "Choose an anchor habit"}
|
||||
</Text>
|
||||
<ChevronDown size={20} color={colors.outline} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Time */}
|
||||
<View style={styles.inputGroup}>
|
||||
<TimeInput
|
||||
label="WHAT TIME?"
|
||||
value={preferredTime}
|
||||
onChange={setPreferredTime}
|
||||
/>
|
||||
<View style={styles.presetsGrid}>
|
||||
{TIME_PRESETS.map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p.time}
|
||||
style={[
|
||||
styles.presetBtn,
|
||||
preferredTime === p.time && {
|
||||
backgroundColor: colors.primaryContainer,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
setPreferredTime(p.time);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.presetText,
|
||||
preferredTime === p.time && {
|
||||
color: colors.primary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{p.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Frequency */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>FREQUENCY</Text>
|
||||
<View style={styles.frequencyGrid}>
|
||||
{FREQUENCIES.map((f) => (
|
||||
<TouchableOpacity
|
||||
key={f.value}
|
||||
style={[
|
||||
styles.frequencyOption,
|
||||
frequency === f.value && {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setFrequency(f.value)}
|
||||
>
|
||||
<Repeat
|
||||
size={16}
|
||||
color={
|
||||
frequency === f.value
|
||||
? colors.onPrimary
|
||||
: colors.onSurfaceVariant
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.frequencyText,
|
||||
frequency === f.value && {
|
||||
color: colors.onPrimary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{f.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weekend Flexibility */}
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchContent}>
|
||||
<View style={styles.rowAlign}>
|
||||
<Calendar
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.switchTitle}>Weekend Flexibility</Text>
|
||||
</View>
|
||||
<Text style={styles.switchDesc}>
|
||||
Allow skipping on weekends without breaking streaks.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={weekendFlexibility}
|
||||
onValueChange={setWeekendFlexibility}
|
||||
trackColor={{
|
||||
false: colors.surfaceVariant,
|
||||
true: colors.primary + "80",
|
||||
}}
|
||||
thumbColor={
|
||||
weekendFlexibility ? colors.primary : colors.outline
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
"Every action you take is a vote for the type of person you wish to become."
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* Anchor Habit Modal */}
|
||||
<Modal
|
||||
visible={showAnchorModal}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setShowAnchorModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={[styles.modalContent, { backgroundColor: colors.surface }]}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Select Anchor Habit</Text>
|
||||
<TouchableOpacity onPress={() => setShowAnchorModal(false)}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={styles.anchorList}>
|
||||
<TouchableOpacity
|
||||
style={[styles.anchorItem, !anchorHabitId && { backgroundColor: colors.primaryContainer }]}
|
||||
onPress={() => {
|
||||
setAnchorHabitId(null);
|
||||
setShowAnchorModal(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.anchorItemText, !anchorHabitId && { color: colors.primary, fontFamily: FONTS.labelSm }]}>
|
||||
No Anchor (Independent)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{existingHabits.map((habit) => (
|
||||
<TouchableOpacity
|
||||
key={habit.id}
|
||||
style={[styles.anchorItem, anchorHabitId === habit.id && { backgroundColor: colors.primaryContainer }]}
|
||||
onPress={() => {
|
||||
setAnchorHabitId(habit.id);
|
||||
setShowAnchorModal(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.anchorItemText, anchorHabitId === habit.id && { color: colors.primary, fontFamily: FONTS.labelSm }]}>
|
||||
{habit.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
section: {
|
||||
gap: SPACING.xl,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
hintText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 2,
|
||||
},
|
||||
presetsGrid: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
presetBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: ROUNDNESS.sm,
|
||||
backgroundColor: colors.surfaceVariant + "4D",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
presetText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
frequencyGrid: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
frequencyOption: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
backgroundColor: colors.surface,
|
||||
paddingVertical: 12,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
frequencyText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 13,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: colors.surface,
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
switchContent: {
|
||||
flex: 1,
|
||||
paddingRight: 16,
|
||||
},
|
||||
rowAlign: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
switchDesc: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 13,
|
||||
color: colors.onSurfaceVariant,
|
||||
lineHeight: 18,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: colors.primaryContainer + "40",
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primaryContainer,
|
||||
marginTop: SPACING.xxl,
|
||||
alignItems: "center",
|
||||
},
|
||||
infoText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: ROUNDNESS.xl,
|
||||
borderTopRightRadius: ROUNDNESS.xl,
|
||||
paddingBottom: 40,
|
||||
maxHeight: '70%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + '4D',
|
||||
},
|
||||
modalTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 18,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
anchorList: {
|
||||
padding: SPACING.md,
|
||||
},
|
||||
anchorItem: {
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
marginBottom: 8,
|
||||
},
|
||||
anchorItemText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { StyleSheet, View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, Save, Link as LinkIcon, Type, Sparkles } from 'lucide-react-native';
|
||||
import { SPACING, FONTS, ROUNDNESS } from '@/src/constants/Theme';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
import { performMutation } from '@/src/lib/sync';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuth } from '@/src/hooks/useAuth';
|
||||
|
||||
export default function AddShortcutScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title || !url) {
|
||||
Alert.alert('Error', 'Please provide both a title and a URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await performMutation('shortcuts', 'INSERT', {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
user_id: user?.id || 'guest',
|
||||
title,
|
||||
url: url.startsWith('http') ? url : `https://${url}`,
|
||||
icon: 'sparkles',
|
||||
});
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('Failed to save shortcut:', error);
|
||||
Alert.alert('Error', 'Failed to save shortcut');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Add Shortcut</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<Save size={24} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>SHORTCUT DETAILS</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>DISPLAY NAME</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Type size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. Project Dashboard"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>URL OR APP LINK</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<LinkIcon size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. linear.app/my-team"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Sparkles size={20} color={colors.primary} />
|
||||
<Text style={styles.infoText}>
|
||||
Shortcuts will appear in your Ecosystem Tools section for quick access to your external workspaces.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + '4D',
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
section: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + '4D',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.primaryContainer + '40',
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
gap: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primaryContainer,
|
||||
marginTop: SPACING.xxl,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { StyleSheet, View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, Save, Type, Tag, Target } from 'lucide-react-native';
|
||||
import { SPACING, FONTS, ROUNDNESS } from '@/src/constants/Theme';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
import { performMutation } from '@/src/lib/sync';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuth } from '@/src/hooks/useAuth';
|
||||
import { getDb } from '@/src/db/database';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
import { getLocalDateString } from '@/src/lib/date-utils';
|
||||
|
||||
const toMinutes = (time: string) => {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
const toTimeString = (minutes: number) => {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default function AddTaskScreen() {
|
||||
const { colors, sprintDuration } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [tag, setTag] = useState('');
|
||||
const [sessions, setSessions] = useState('1');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
Alert.alert('Error', 'Please provide a title for your task');
|
||||
return;
|
||||
}
|
||||
|
||||
const estSessions = parseInt(sessions) || 1;
|
||||
const taskId = Math.random().toString(36).substring(7);
|
||||
const userId = user?.id || 'guest';
|
||||
const today = getLocalDateString();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 1. Save the Task
|
||||
await performMutation('tasks', 'INSERT', {
|
||||
id: taskId,
|
||||
user_id: userId,
|
||||
title: title.trim(),
|
||||
status: 'todo',
|
||||
estimated_sessions: estSessions,
|
||||
completed_sessions: 0,
|
||||
tag: tag.trim() || 'General',
|
||||
todos: '[]',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 2. Automatically Add to Schedule
|
||||
const db = await getDb();
|
||||
const existingSchedule = await db.getFirstAsync<{id: string, time_blocks: string}>(
|
||||
"SELECT id, time_blocks FROM schedules WHERE date = ? AND (user_id = ? OR user_id IS NULL)",
|
||||
[today, userId]
|
||||
);
|
||||
|
||||
let blocks = [];
|
||||
let scheduleId = Math.random().toString(36).substring(7);
|
||||
|
||||
if (existingSchedule) {
|
||||
try {
|
||||
blocks = JSON.parse(existingSchedule.time_blocks);
|
||||
scheduleId = existingSchedule.id;
|
||||
} catch (e) { blocks = []; }
|
||||
}
|
||||
|
||||
// Find first available slot after 9 AM
|
||||
let currentStart = toMinutes("09:00");
|
||||
const duration = sprintDuration;
|
||||
|
||||
const sortedBlocks = [...blocks].sort((a, b) => a.start.localeCompare(b.start));
|
||||
|
||||
for (const block of sortedBlocks) {
|
||||
const blockStart = toMinutes(block.start);
|
||||
if (blockStart >= currentStart + duration) {
|
||||
break;
|
||||
}
|
||||
currentStart = Math.max(currentStart, toMinutes(block.end));
|
||||
}
|
||||
|
||||
const newBlock = {
|
||||
start: toTimeString(currentStart),
|
||||
end: toTimeString(currentStart + duration),
|
||||
task: title.trim(),
|
||||
type: 'deep-work',
|
||||
todos: []
|
||||
};
|
||||
|
||||
const updatedBlocks = [...blocks, newBlock].sort((a, b) => a.start.localeCompare(b.start));
|
||||
|
||||
await performMutation('schedules', existingSchedule ? 'UPDATE' : 'INSERT', {
|
||||
id: scheduleId,
|
||||
user_id: userId,
|
||||
date: today,
|
||||
time_blocks: JSON.stringify(updatedBlocks)
|
||||
});
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('Failed to save task/schedule:', error);
|
||||
Alert.alert('Error', 'Failed to save task');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>New Task</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<Save size={24} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>TASK DETAILS</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>WHAT ARE YOU WORKING ON?</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Type size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. Audit Typography Hierarchy"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>TAG / CATEGORY</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Tag size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. Design, Coding, Admin"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={tag}
|
||||
onChangeText={setTag}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>ESTIMATED SESSIONS (25m each)</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Target size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="1"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={sessions}
|
||||
onChangeText={setSessions}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
Break large tasks into small, actionable chunks to maintain flow and momentum.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + '4D',
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
section: {
|
||||
gap: SPACING.xl,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + '4D',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: colors.primaryContainer + '40',
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primaryContainer,
|
||||
marginTop: SPACING.xxl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Redirect } from 'expo-router';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const { colors } = useTheme();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.background }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/(tabs)/" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { StyleSheet, View, Text, TextInput, TouchableOpacity, ScrollView, Image, ActivityIndicator, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Mail, Lock, LogIn, UserPlus, Sparkles, ArrowRight } from 'lucide-react-native';
|
||||
import { SPACING, FONTS, ROUNDNESS } from '@/src/constants/Theme';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
import { supabase } from '@/src/lib/supabase';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
|
||||
async function handleAuth() {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isSignUp) {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) throw error;
|
||||
Alert.alert('Success', 'Check your email for the confirmation link!');
|
||||
} else {
|
||||
const { data: authData, error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (signInError) throw signInError;
|
||||
|
||||
// Ensure profile exists
|
||||
if (authData?.user) {
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: authData.user.id,
|
||||
email: authData.user.email,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (profileError) {
|
||||
console.error('Error ensuring profile exists:', profileError.message);
|
||||
}
|
||||
}
|
||||
|
||||
router.replace('/(tabs)/');
|
||||
}
|
||||
} catch (error: any) {
|
||||
Alert.alert('Auth Error', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/Artboard 1 logo.png')}
|
||||
style={[styles.logoImage, { tintColor: colors.primary }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.title}>{isSignUp ? 'Join Batsir' : 'Welcome Back'}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{isSignUp ? 'Start your journey to atomic efficiency.' : 'Find your flow state and continue building.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>EMAIL ADDRESS</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Mail size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="name@example.com"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>PASSWORD</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Lock size={20} color={colors.outline} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Your secure password"
|
||||
placeholderTextColor={colors.outline}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, loading && { opacity: 0.7 }]}
|
||||
onPress={handleAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.primaryBtnText}>{isSignUp ? 'Create Account' : 'Sign In'}</Text>
|
||||
{isSignUp ? <UserPlus size={18} color={colors.onPrimary} /> : <LogIn size={18} color={colors.onPrimary} />}
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.switchBtn}
|
||||
onPress={() => setIsSignUp(!isSignUp)}
|
||||
>
|
||||
<Text style={styles.switchBtnText}>
|
||||
{isSignUp ? 'Already have an account? Sign In' : "Don't have an account? Sign Up"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!isSignUp && (
|
||||
<View style={styles.featureHighlight}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Sparkles size={20} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.featureContent}>
|
||||
<Text style={styles.featureTitle}>Batsirai Integration</Text>
|
||||
<Text style={styles.featureDesc}>Your personal flow assistant is ready to help you optimize your schedule.</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.guestBtn} onPress={() => router.replace('/(tabs)/')}>
|
||||
<Text style={styles.guestBtnText}>Continue as Guest</Text>
|
||||
<ArrowRight size={14} color={colors.outline} />
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: SPACING.xl,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
marginBottom: SPACING.xxl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
logoImage: {
|
||||
height: 48,
|
||||
width: 180,
|
||||
},
|
||||
title: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 32,
|
||||
color: colors.onSurface,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + '4D',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
height: 56,
|
||||
borderRadius: ROUNDNESS.full,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginTop: 10,
|
||||
elevation: 2,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 16,
|
||||
},
|
||||
switchBtn: {
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
switchBtnText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
featureHighlight: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.primaryContainer + '40',
|
||||
padding: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
gap: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primaryContainer,
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
featureIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
featureContent: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
marginBottom: 2,
|
||||
},
|
||||
featureDesc: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 13,
|
||||
color: colors.onSurfaceVariant,
|
||||
lineHeight: 18,
|
||||
},
|
||||
guestBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
},
|
||||
guestBtnText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 14,
|
||||
color: colors.outline,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { FONTS, ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Save,
|
||||
Sparkles,
|
||||
Target,
|
||||
X,
|
||||
History
|
||||
} from "lucide-react-native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function MenuModal() {
|
||||
const {
|
||||
colors,
|
||||
identityAnchor,
|
||||
updateIdentityAnchor,
|
||||
focusGoal,
|
||||
updateFocusGoal,
|
||||
} = useTheme();
|
||||
const router = useRouter();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
const [newAnchor, setNewAnchor] = useState(identityAnchor);
|
||||
const [newGoal, setNewGoal] = useState(focusGoal.toString());
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
await updateIdentityAnchor(newAnchor);
|
||||
const goalNum = parseInt(newGoal);
|
||||
if (!isNaN(goalNum)) {
|
||||
await updateFocusGoal(goalNum);
|
||||
}
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "Sprint",
|
||||
subtitle: "Start a focused work session",
|
||||
icon: <Clock size={22} color={colors.primary} />,
|
||||
route: "/sprint" as const,
|
||||
},
|
||||
{
|
||||
title: "History",
|
||||
subtitle: "Browse past activities & inventory",
|
||||
icon: <History size={22} color={colors.primary} />,
|
||||
route: "/history" as const,
|
||||
},
|
||||
{
|
||||
title: "Assistant",
|
||||
subtitle: "Chat with Batsirai AI",
|
||||
icon: <Sparkles size={22} color={colors.primary} />,
|
||||
route: "/aa_ai" as const,
|
||||
},
|
||||
{
|
||||
title: "Habits",
|
||||
subtitle: "Manage your daily evolution",
|
||||
icon: <Activity size={22} color={colors.primary} />,
|
||||
route: "/hh_habits" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const navigateTo = (route: any) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.replace(route);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Navigation</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.closeBtn}
|
||||
>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>MAIN TOOLS</Text>
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.menuItem}
|
||||
onPress={() => navigateTo(item.route)}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={styles.iconBg}>{item.icon}</View>
|
||||
<View>
|
||||
<Text style={styles.menuItemTitle}>{item.title}</Text>
|
||||
<Text style={styles.menuItemSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={18} color={colors.outline} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>BATSIRAI / ECOSYSTEM</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
section: {
|
||||
gap: 8,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: 8,
|
||||
},
|
||||
settingsCard: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
gap: 16,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
rowAlign: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
inputLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 10,
|
||||
color: colors.outline,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surfaceVariant + "40",
|
||||
borderRadius: ROUNDNESS.md,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "26",
|
||||
},
|
||||
saveBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 12,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
marginTop: 8,
|
||||
},
|
||||
saveBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 14,
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
marginBottom: 8,
|
||||
},
|
||||
menuItemLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
},
|
||||
iconBg: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
backgroundColor: colors.primaryContainer + "40",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 16,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
menuItemSubtitle: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
marginTop: 2,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 40,
|
||||
alignItems: "center",
|
||||
paddingBottom: 40,
|
||||
},
|
||||
footerText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
color: colors.outline,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,662 @@
|
|||
import { AccentKey, FONTS, ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { ThemeMode, useTheme } from "@/src/hooks/useTheme";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Edit3,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Sun,
|
||||
Target,
|
||||
X,
|
||||
Shield,
|
||||
Database,
|
||||
Fingerprint
|
||||
} from "lucide-react-native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
KeyboardAvoidingView,
|
||||
Platform
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { pullFromServer, syncWithSupabase } from "../lib/sync";
|
||||
|
||||
export default function SettingsModal() {
|
||||
const {
|
||||
colors,
|
||||
accentKey,
|
||||
updateAccent,
|
||||
availableAccents,
|
||||
themeMode,
|
||||
updateThemeMode,
|
||||
focusGoal,
|
||||
updateFocusGoal,
|
||||
sprintDuration,
|
||||
updateSprintDuration,
|
||||
displayName,
|
||||
updateDisplayName,
|
||||
identityAnchor,
|
||||
updateIdentityAnchor,
|
||||
} = useTheme();
|
||||
|
||||
const [newName, setNewName] = useState(displayName);
|
||||
const [newIdentity, setNewIdentity] = useState(identityAnchor);
|
||||
const [newGoal, setNewGoal] = useState(focusGoal.toString());
|
||||
const [newSprintDuration, setNewSprintDuration] = useState(
|
||||
sprintDuration.toString(),
|
||||
);
|
||||
|
||||
const [isEditingAccount, setIsEditingAccount] = useState(false);
|
||||
const [isEditingIdentity, setIsEditingIdentity] = useState(false);
|
||||
const [isEditingGoals, setIsEditingGoals] = useState(false);
|
||||
|
||||
const accountDirty = newName.trim() !== displayName.trim();
|
||||
const identityDirty = newIdentity.trim() !== identityAnchor.trim();
|
||||
const goalsDirty =
|
||||
newGoal.trim() !== focusGoal.toString() ||
|
||||
newSprintDuration.trim() !== sprintDuration.toString();
|
||||
|
||||
const { signOut, user } = useAuth();
|
||||
const router = useRouter();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const handleSaveIdentity = async () => {
|
||||
if (!identityDirty) return;
|
||||
try {
|
||||
await updateIdentityAnchor(newIdentity);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setIsEditingIdentity(false);
|
||||
} catch (e) {
|
||||
Alert.alert("Error", "Unable to save identity anchor.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!accountDirty) return;
|
||||
try {
|
||||
await updateDisplayName(newName);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setIsEditingAccount(false);
|
||||
} catch (e) {
|
||||
Alert.alert("Error", "Unable to save display name.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGoals = async () => {
|
||||
if (!goalsDirty) return;
|
||||
const goalNum = parseInt(newGoal, 10);
|
||||
const sprintNum = parseInt(newSprintDuration, 10);
|
||||
|
||||
if (isNaN(goalNum) || isNaN(sprintNum)) {
|
||||
Alert.alert("Error", "Please enter valid numbers.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateFocusGoal(goalNum);
|
||||
await updateSprintDuration(sprintNum);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setIsEditingGoals(false);
|
||||
} catch (e) {
|
||||
Alert.alert("Error", "Unable to save goals.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace("/login");
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleManualSync = async () => {
|
||||
setSyncing(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
try {
|
||||
await syncWithSupabase();
|
||||
await pullFromServer();
|
||||
Alert.alert("Cloud Sync Successful", "Your flow is now synchronized across all devices.");
|
||||
} catch (e) {
|
||||
Alert.alert("Sync Error", "Could not connect to the cloud architect.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetData = () => {
|
||||
Alert.alert(
|
||||
"Hard Reset",
|
||||
"This will clear all local data. Cloud data will remain safe. Proceed?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Reset",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
Alert.alert("Reset Complete", "Local data has been purged.");
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea} edges={["bottom"]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>System Configuration</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.closeBtn}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||
{/* Identity Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Fingerprint size={16} color={colors.primary} />
|
||||
<Text style={styles.sectionLabel}>IDENTITY PRINCIPLE</Text>
|
||||
</View>
|
||||
<Text style={styles.sectionDesc}>
|
||||
This is the "Identity Anchor" that guides your habits. Define who you are becoming.
|
||||
</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ minHeight: 80, textAlignVertical: 'top', paddingTop: 12 },
|
||||
!isEditingIdentity && styles.inputDisabled,
|
||||
]}
|
||||
value={newIdentity}
|
||||
onChangeText={setNewIdentity}
|
||||
placeholder="I am the type of person who..."
|
||||
placeholderTextColor={colors.outline}
|
||||
editable={isEditingIdentity}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
{!isEditingIdentity ? (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={() => setIsEditingIdentity(true)}>
|
||||
<Edit3 size={16} color={colors.onSurface} />
|
||||
<Text style={styles.actionBtnText}>Refine Identity</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={() => { setIsEditingIdentity(false); setNewIdentity(identityAnchor); }}>
|
||||
<X size={16} color={colors.error} />
|
||||
<Text style={[styles.actionBtnText, { color: colors.error }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, identityDirty && styles.actionBtnActive]}
|
||||
onPress={handleSaveIdentity}
|
||||
disabled={!identityDirty}
|
||||
>
|
||||
<Save size={16} color={identityDirty ? colors.onPrimary : colors.outline} />
|
||||
<Text style={[styles.actionBtnText, identityDirty && styles.actionBtnTextActive]}>Save Anchor</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Account Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Shield size={16} color={colors.primary} />
|
||||
<Text style={styles.sectionLabel}>ACCOUNT SECURITY</Text>
|
||||
</View>
|
||||
<View style={styles.accountInfo}>
|
||||
<View style={styles.accountDetails}>
|
||||
<Text style={styles.menuItemText}>{user?.email || "Guest User"}</Text>
|
||||
<TextInput
|
||||
style={[styles.nameInput, !isEditingAccount && styles.inputDisabled]}
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
editable={isEditingAccount}
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.signOutBtn} onPress={handleSignOut}>
|
||||
<LogOut size={18} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
{!isEditingAccount ? (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={() => setIsEditingAccount(true)}>
|
||||
<Edit3 size={16} color={colors.onSurface} />
|
||||
<Text style={styles.actionBtnText}>Update Profile</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, accountDirty && styles.actionBtnActive]}
|
||||
onPress={handleSaveAccount}
|
||||
disabled={!accountDirty}
|
||||
>
|
||||
<Save size={16} color={accountDirty ? colors.onPrimary : colors.outline} />
|
||||
<Text style={[styles.actionBtnText, accountDirty && styles.actionBtnTextActive]}>Save Profile</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Productivity Goals */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Target size={16} color={colors.primary} />
|
||||
<Text style={styles.sectionLabel}>PRODUCTIVITY ARCHITECTURE</Text>
|
||||
</View>
|
||||
<View style={styles.goalRow}>
|
||||
<View style={styles.goalInfo}>
|
||||
<Target size={20} color={colors.primary} />
|
||||
<View>
|
||||
<Text style={styles.goalTitle}>Daily Focus Goal</Text>
|
||||
<Text style={styles.goalDesc}>Number of sessions per day</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TextInput
|
||||
style={[styles.goalInput, !isEditingGoals && styles.inputDisabled]}
|
||||
value={newGoal}
|
||||
onChangeText={setNewGoal}
|
||||
keyboardType="number-pad"
|
||||
editable={isEditingGoals}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.goalRow}>
|
||||
<View style={styles.goalInfo}>
|
||||
<Clock size={20} color={colors.primary} />
|
||||
<View>
|
||||
<Text style={styles.goalTitle}>Sprint Duration</Text>
|
||||
<Text style={styles.goalDesc}>Minutes per session</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TextInput
|
||||
style={[styles.goalInput, !isEditingGoals && styles.inputDisabled]}
|
||||
value={newSprintDuration}
|
||||
onChangeText={setNewSprintDuration}
|
||||
keyboardType="number-pad"
|
||||
editable={isEditingGoals}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
{!isEditingGoals ? (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={() => setIsEditingGoals(true)}>
|
||||
<Edit3 size={16} color={colors.onSurface} />
|
||||
<Text style={styles.actionBtnText}>Modify Goals</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, goalsDirty && styles.actionBtnActive]}
|
||||
onPress={handleSaveGoals}
|
||||
disabled={!goalsDirty}
|
||||
>
|
||||
<Save size={16} color={goalsDirty ? colors.onPrimary : colors.outline} />
|
||||
<Text style={[styles.actionBtnText, goalsDirty && styles.actionBtnTextActive]}>Apply Goals</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Visual Style */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>VISUAL INTERFACE</Text>
|
||||
<View style={styles.themeToggleGrid}>
|
||||
{(['light', 'dark', 'system'] as ThemeMode[]).map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode}
|
||||
style={[
|
||||
styles.themeOption,
|
||||
themeMode === mode && { backgroundColor: colors.primary }
|
||||
]}
|
||||
onPress={() => {
|
||||
updateThemeMode(mode);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
{mode === 'light' ? <Sun size={20} color={themeMode === mode ? colors.onPrimary : colors.onSurfaceVariant} /> :
|
||||
mode === 'dark' ? <Moon size={20} color={themeMode === mode ? colors.onPrimary : colors.onSurfaceVariant} /> :
|
||||
<Monitor size={20} color={themeMode === mode ? colors.onPrimary : colors.onSurfaceVariant} />}
|
||||
<Text style={[styles.themeLabel, themeMode === mode && { color: colors.onPrimary }]}>
|
||||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.accentGrid}>
|
||||
{(Object.keys(availableAccents) as AccentKey[]).map((key) => {
|
||||
const accent = availableAccents[key];
|
||||
const isSelected = accentKey === key;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={key}
|
||||
style={[styles.accentCard, isSelected && { borderColor: colors.primary, borderWidth: 2 }]}
|
||||
onPress={() => {
|
||||
updateAccent(key);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<View style={[styles.accentPreview, { backgroundColor: accent.primary }]}>
|
||||
{isSelected && <Check size={20} color="#fff" />}
|
||||
</View>
|
||||
<Text style={[styles.accentLabel, isSelected && { color: colors.primary, fontFamily: FONTS.labelSm }]} numberOfLines={1}>
|
||||
{accent.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* System & Sync */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Database size={16} color={colors.primary} />
|
||||
<Text style={styles.sectionLabel}>CORE ENGINE</Text>
|
||||
</View>
|
||||
<View style={styles.systemCard}>
|
||||
<TouchableOpacity style={styles.systemItem} onPress={handleManualSync} disabled={syncing}>
|
||||
<View style={styles.systemItemInfo}>
|
||||
<Text style={styles.menuItemText}>Cloud Architecture Sync</Text>
|
||||
<Text style={styles.menuItemValue}>Last verified: Just now</Text>
|
||||
</View>
|
||||
{syncing ? <ActivityIndicator size="small" color={colors.primary} /> : <RefreshCw size={18} color={colors.primary} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.systemItem} onPress={handleResetData}>
|
||||
<View style={styles.systemItemInfo}>
|
||||
<Text style={styles.menuItemText}>Purge Local Cache</Text>
|
||||
<Text style={styles.menuItemValue}>Hard reset database</Text>
|
||||
</View>
|
||||
<Database size={18} color={colors.error} opacity={0.7} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.versionText}>Batsirai Productivity Planner v1.2.0</Text>
|
||||
<Text style={styles.versionText}>Engineered for Focus</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: SPACING.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
title: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 22,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
section: {
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
sectionHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.primary,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
sectionDesc: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 13,
|
||||
color: colors.onSurfaceVariant,
|
||||
lineHeight: 18,
|
||||
marginBottom: 12,
|
||||
},
|
||||
accountInfo: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
accountDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
nameInput: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 15,
|
||||
color: colors.primary,
|
||||
marginTop: 4,
|
||||
},
|
||||
signOutBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
borderRadius: ROUNDNESS.md,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontFamily: FONTS.body,
|
||||
color: colors.onSurface,
|
||||
fontSize: 15,
|
||||
},
|
||||
inputDisabled: {
|
||||
opacity: 0.6,
|
||||
backgroundColor: colors.surfaceVariant + '40',
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "80",
|
||||
},
|
||||
actionBtnActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontFamily: FONTS.label,
|
||||
color: colors.onSurface,
|
||||
fontSize: 13,
|
||||
},
|
||||
actionBtnTextActive: {
|
||||
color: colors.onPrimary,
|
||||
},
|
||||
goalRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: colors.surface,
|
||||
padding: 14,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
goalInfo: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
goalTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 15,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
goalDesc: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
goalInput: {
|
||||
width: 60,
|
||||
height: 40,
|
||||
backgroundColor: colors.surfaceVariant + "80",
|
||||
borderRadius: ROUNDNESS.sm,
|
||||
textAlign: "center",
|
||||
fontFamily: FONTS.labelSm,
|
||||
color: colors.primary,
|
||||
fontSize: 18,
|
||||
},
|
||||
themeToggleGrid: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
themeOption: {
|
||||
flex: 1,
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
gap: 8,
|
||||
},
|
||||
themeLabel: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
accentGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
},
|
||||
accentCard: {
|
||||
width: "31%",
|
||||
backgroundColor: colors.surface,
|
||||
padding: 10,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
accentPreview: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
accentLabel: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 11,
|
||||
color: colors.onSurfaceVariant,
|
||||
},
|
||||
systemCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
systemItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.outlineVariant + "33",
|
||||
},
|
||||
systemItemInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
menuItemText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
menuItemValue: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
marginTop: 2,
|
||||
},
|
||||
footer: {
|
||||
alignItems: "center",
|
||||
marginTop: SPACING.xl,
|
||||
paddingBottom: 60,
|
||||
},
|
||||
versionText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, Alert } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
|
||||
import { getDb } from '@/src/db/database';
|
||||
import { performMutation } from '@/src/lib/sync';
|
||||
import { resolveFileUri, downloadBook } from '@/src/lib/file-utils';
|
||||
import PdfReader from '@/src/components/Library/PdfReader';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
file_uri: string;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function ReaderScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const [book, setBook] = useState<Book | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [localUri, setLocalUri] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [startPage, setStartPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [sessionSeconds, setSessionSeconds] = useState(0);
|
||||
|
||||
const sessionStartTime = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
loadBook();
|
||||
}, [id]);
|
||||
|
||||
const loadBook = async () => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const bookData = await db.getFirstAsync<Book>(
|
||||
'SELECT * FROM books WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (bookData) {
|
||||
setBook(bookData);
|
||||
setCurrentPage(bookData.current_page || 0);
|
||||
setStartPage(bookData.current_page || 0);
|
||||
setTotalPages(bookData.total_pages || 0);
|
||||
|
||||
// Ensure file is local
|
||||
try {
|
||||
setDownloading(true);
|
||||
const uri = await downloadBook(bookData.file_uri);
|
||||
setLocalUri(uri);
|
||||
} catch (e) {
|
||||
console.error('Failed to ensure local file:', e);
|
||||
// Fallback to resolved URI (might be a remote URL if docDir was null)
|
||||
setLocalUri(resolveFileUri(bookData.file_uri));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Error', 'Book not found');
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load book:', error);
|
||||
Alert.alert('Error', 'Failed to load book');
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!book) return;
|
||||
|
||||
const durationSeconds = Math.floor((Date.now() - sessionStartTime.current) / 1000);
|
||||
const pagesRead = Math.max(0, currentPage - startPage);
|
||||
|
||||
try {
|
||||
// Update book progress
|
||||
await performMutation('books', 'UPDATE', {
|
||||
id: book.id,
|
||||
current_page: currentPage,
|
||||
total_pages: totalPages,
|
||||
status: totalPages > 0 && currentPage >= totalPages - 1 ? 'finished' : 'reading',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Log reading session
|
||||
if (pagesRead > 0 || durationSeconds > 30) {
|
||||
await performMutation('reading_logs', 'INSERT', {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
book_id: book.id,
|
||||
start_page: startPage,
|
||||
end_page: currentPage,
|
||||
pages_read: pagesRead,
|
||||
duration_seconds: durationSeconds,
|
||||
duration_minutes: durationSeconds / 60,
|
||||
logged_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('Failed to save progress:', error);
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = useCallback((page: number, total: number) => {
|
||||
setCurrentPage(page);
|
||||
if (total > 0 && total !== totalPages) {
|
||||
setTotalPages(total);
|
||||
}
|
||||
}, [totalPages]);
|
||||
|
||||
if (loading || (downloading && !localUri)) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: '#000' }]}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
{downloading && (
|
||||
<ThemedText style={{ color: '#fff', marginTop: 16 }}>
|
||||
Downloading your book...
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!book || !localUri) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen options={{ headerShown: false, animation: 'fade' }} />
|
||||
<PdfReader
|
||||
uri={localUri}
|
||||
title={book.title}
|
||||
initialPage={currentPage}
|
||||
onClose={handleClose}
|
||||
onPageChange={handlePageChange}
|
||||
onSessionUpdate={setSessionSeconds}
|
||||
onAddNote={(page) => {
|
||||
// Future: Open note editor
|
||||
console.log('Add note for page:', page);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { LayoutDashboard } from "lucide-react-native";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { Animated, Platform, Pressable, StyleSheet, View } from "react-native";
|
||||
|
||||
type Props = {
|
||||
onPress: () => void;
|
||||
accentColor: string;
|
||||
label?: string;
|
||||
position?: "center" | "right";
|
||||
};
|
||||
|
||||
export default function AutoHideFloatingActionButton({
|
||||
onPress,
|
||||
accentColor,
|
||||
label = "Day",
|
||||
position = "center",
|
||||
}: Props) {
|
||||
const { isDark, colors } = useTheme();
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const onPressIn = useCallback(() => {
|
||||
// Requirement: Animated scale on press (0.95 -> 1.0)
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.95,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
}).start();
|
||||
}, [scaleAnim]);
|
||||
|
||||
const onPressOut = useCallback(() => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
}).start();
|
||||
}, [scaleAnim]);
|
||||
|
||||
const animatedStyle = useMemo(
|
||||
() => ({ transform: [{ scale: scaleAnim }] }),
|
||||
[scaleAnim],
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.wrapper,
|
||||
animatedStyle,
|
||||
position === "right" ? styles.positionRight : styles.positionCenter,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Go to ${label}`}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
style={({ pressed }) => [
|
||||
styles.pressable,
|
||||
{ opacity: pressed ? 0.95 : 1 },
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
// Requirement: Blur amount 15-20
|
||||
intensity={Platform.OS === "ios" ? 30 : 0}
|
||||
tint={isDark ? "dark" : "light"}
|
||||
style={[
|
||||
styles.fab,
|
||||
{
|
||||
backgroundColor: isDark ? "rgba(22,22,24,0.72)" : "rgba(248,250,249,0.85)",
|
||||
borderColor: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.05)",
|
||||
},
|
||||
Platform.OS === "android" && {
|
||||
backgroundColor: isDark ? "rgba(28,28,30,0.96)" : colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconContainer]}>
|
||||
<LayoutDashboard size={24} color={accentColor} strokeWidth={2.2} />
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
// Requirement: Large border radius (24-28px) -> 28 for 56x56
|
||||
borderRadius: 30,
|
||||
// Requirement: Slight elevation/shadow
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 10,
|
||||
},
|
||||
pressable: {
|
||||
borderRadius: 30,
|
||||
overflow: "hidden",
|
||||
},
|
||||
positionCenter: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
positionRight: {
|
||||
alignSelf: "flex-end",
|
||||
marginRight: 20,
|
||||
},
|
||||
fab: {
|
||||
// Requirement: Circular (56x56 points)
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
borderWidth: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconContainer: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { useAutoHideOnScroll } from "@/src/hooks/useAutoHideTabBar";
|
||||
import React from "react";
|
||||
import { ScrollView, ScrollViewProps } from "react-native";
|
||||
|
||||
const AutoHideScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
||||
(props, ref) => {
|
||||
const { onScroll, scrollEventThrottle } = useAutoHideOnScroll();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={ref}
|
||||
{...props}
|
||||
scrollEventThrottle={scrollEventThrottle}
|
||||
onScroll={(event) => {
|
||||
onScroll(event);
|
||||
if (props.onScroll) {
|
||||
props.onScroll(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default AutoHideScrollView;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { FONTS, SPACING, ThemeColors } from "@/src/constants/Theme";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, message: "" };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, message: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Chat error boundary caught:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError, message } = this.state;
|
||||
const { colors, children } = this.props;
|
||||
|
||||
if (!hasError) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.title, { color: colors.onBackground }]}>
|
||||
Something went wrong.
|
||||
</Text>
|
||||
<Text style={[styles.details, { color: colors.onSurfaceVariant }]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
title: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
details: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { Sparkles } from "lucide-react-native";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Keyboard,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from "react-native";
|
||||
|
||||
export default function FloatingAIDock() {
|
||||
const { colors, isDark } = useTheme();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const visibilityAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Keyboard awareness - hide when typing
|
||||
useEffect(() => {
|
||||
const showSubscription = Keyboard.addListener(
|
||||
Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",
|
||||
() => setIsVisible(false)
|
||||
);
|
||||
const hideSubscription = Keyboard.addListener(
|
||||
Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",
|
||||
() => setIsVisible(true)
|
||||
);
|
||||
|
||||
return () => {
|
||||
showSubscription.remove();
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(visibilityAnim, {
|
||||
toValue: isVisible ? 1 : 0,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
}).start();
|
||||
}, [isVisible, visibilityAnim]);
|
||||
|
||||
const navigateToAI = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
router.push("/aa_ai");
|
||||
}, [router]);
|
||||
|
||||
const isActive = pathname === "/aa_ai";
|
||||
|
||||
const dockStyle = useMemo(() => [
|
||||
styles.dockContainer,
|
||||
{
|
||||
right: 16,
|
||||
top: "55%", // Slightly below center
|
||||
transform: [
|
||||
{ scale: visibilityAnim },
|
||||
{ translateX: visibilityAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [100, 0],
|
||||
})
|
||||
},
|
||||
],
|
||||
opacity: visibilityAnim,
|
||||
}
|
||||
], [visibilityAnim]);
|
||||
|
||||
// Don't show the launcher if we're already on the AI screen
|
||||
if (isActive) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={dockStyle}>
|
||||
<Pressable
|
||||
onPress={navigateToAI}
|
||||
style={({ pressed }) => [
|
||||
styles.pressable,
|
||||
{ transform: [{ scale: pressed ? 0.92 : 1 }] }
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "ios" ? 60 : 0}
|
||||
tint={isDark ? "dark" : "light"}
|
||||
style={[
|
||||
styles.blurContainer,
|
||||
{
|
||||
backgroundColor: isDark ? "rgba(22,22,24,0.72)" : "rgba(248,250,249,0.85)",
|
||||
borderColor: isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)",
|
||||
},
|
||||
Platform.OS === "android" && {
|
||||
backgroundColor: isDark ? "rgba(28,28,30,0.96)" : colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconWrapper}>
|
||||
<Sparkles size={28} color={colors.primary} strokeWidth={2.2} />
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dockContainer: {
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
width: 64,
|
||||
height: 64,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 15,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 20,
|
||||
},
|
||||
pressable: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
flex: 1,
|
||||
borderRadius: 32,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1.5,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconWrapper: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
// Subtle glow effect
|
||||
shadowColor: "#FFF",
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
import AutoHideFloatingActionButton from "@/src/components/AutoHideFloatingActionButton";
|
||||
import { FONTS } from "@/src/constants/Theme";
|
||||
import { useTabBarVisibility } from "@/src/hooks/useAutoHideTabBar";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const MAIN_TABS = ["index", "calendar", "library"];
|
||||
|
||||
export default function GlassBottomTabBar(props: BottomTabBarProps) {
|
||||
const { state, descriptors, navigation } = props;
|
||||
const { colors, isDark } = useTheme();
|
||||
|
||||
const { tabBarVisible, showTabBar } = useTabBarVisibility();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const currentRouteName = state.routes[state.index].name;
|
||||
|
||||
const animated = useRef(new Animated.Value(tabBarVisible ? 0 : 1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(animated, {
|
||||
toValue: tabBarVisible ? 0 : 1,
|
||||
damping: 18,
|
||||
stiffness: 180,
|
||||
mass: 0.9,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [tabBarVisible, animated]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.container,
|
||||
{
|
||||
bottom: insets.bottom + 12,
|
||||
transform: [
|
||||
{
|
||||
translateY: animated.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 140],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: animated.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[animated, insets.bottom],
|
||||
);
|
||||
|
||||
const fabStyle = useMemo(
|
||||
() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: animated.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [160, 0],
|
||||
}),
|
||||
},
|
||||
{
|
||||
scale: animated.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0.7],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: animated.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
}),
|
||||
}),
|
||||
[animated],
|
||||
);
|
||||
|
||||
const indexRoute = state.routes.find((route) => route.name === "index");
|
||||
|
||||
const onMostImportantPress = () => {
|
||||
if (currentRouteName === "index" && indexRoute) {
|
||||
const event = navigation.emit({
|
||||
type: "tabPress",
|
||||
target: indexRoute.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
navigation.navigate("index");
|
||||
}
|
||||
} else {
|
||||
navigation.navigate("index");
|
||||
}
|
||||
|
||||
showTabBar();
|
||||
};
|
||||
|
||||
if (currentRouteName === "aa_ai") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleRoutes = state.routes.filter((r) => MAIN_TABS.includes(r.name));
|
||||
|
||||
const indexRouteKey = visibleRoutes.find((r) => r.name === "index")?.key;
|
||||
const indexActiveColor =
|
||||
(descriptors[indexRouteKey || ""].options
|
||||
.tabBarActiveTintColor as string) || colors.primary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
style={containerStyle}
|
||||
pointerEvents={tabBarVisible ? "auto" : "none"}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "ios" ? 70 : 0}
|
||||
tint={isDark ? "dark" : "light"}
|
||||
style={[
|
||||
styles.blurContainer,
|
||||
{
|
||||
backgroundColor: isDark ? "rgba(22,22,24,0.72)" : "rgba(248,250,249,0.85)",
|
||||
borderColor: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.05)",
|
||||
shadowColor: isDark ? "#000" : colors.outline,
|
||||
},
|
||||
Platform.OS === "android" && {
|
||||
backgroundColor: isDark ? "rgba(28,28,30,0.96)" : colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
{visibleRoutes.map((route) => {
|
||||
const descriptor = descriptors[route.key];
|
||||
|
||||
const focused = currentRouteName === route.name;
|
||||
|
||||
const activeColor =
|
||||
(descriptor.options.tabBarActiveTintColor as string) ||
|
||||
colors.primary;
|
||||
|
||||
const inactiveColor =
|
||||
(descriptor.options.tabBarInactiveTintColor as string) ||
|
||||
colors.outline;
|
||||
|
||||
const color = focused ? activeColor : inactiveColor;
|
||||
|
||||
const label = descriptor.options.title || route.name;
|
||||
|
||||
const icon = descriptor.options.tabBarIcon?.({
|
||||
focused,
|
||||
color,
|
||||
size: 24,
|
||||
});
|
||||
|
||||
const onPress = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const event = navigation.emit({
|
||||
type: "tabPress",
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={route.key}
|
||||
onPress={onPress}
|
||||
style={styles.tabWrapper}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.tabButton,
|
||||
focused && {
|
||||
backgroundColor: isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.04)",
|
||||
borderColor: isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.02)",
|
||||
},
|
||||
focused && styles.activeTabButton,
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconWrapper}>{icon}</View>
|
||||
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color,
|
||||
fontWeight: focused ? "700" : "500",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.fabHost,
|
||||
fabStyle,
|
||||
{
|
||||
bottom: insets.bottom + 28,
|
||||
},
|
||||
]}
|
||||
pointerEvents={tabBarVisible ? "none" : "auto"}
|
||||
>
|
||||
<AutoHideFloatingActionButton
|
||||
onPress={onMostImportantPress}
|
||||
accentColor={indexActiveColor}
|
||||
label="Day"
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
alignSelf: "center",
|
||||
zIndex: 100,
|
||||
},
|
||||
|
||||
blurContainer: {
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
|
||||
borderWidth: 1,
|
||||
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
|
||||
elevation: 20,
|
||||
},
|
||||
|
||||
inner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
},
|
||||
|
||||
tabWrapper: {
|
||||
borderRadius: 999,
|
||||
},
|
||||
|
||||
tabButton: {
|
||||
minWidth: 76,
|
||||
height: 44,
|
||||
|
||||
borderRadius: 999,
|
||||
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
|
||||
activeTabButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
iconWrapper: {
|
||||
marginBottom: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
label: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
letterSpacing: -0.1,
|
||||
textAlign: "center",
|
||||
lineHeight: 12,
|
||||
},
|
||||
|
||||
fabHost: {
|
||||
position: "absolute",
|
||||
alignSelf: "center",
|
||||
zIndex: 101,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Upload, X } from "lucide-react-native";
|
||||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface AddBookModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
bookTitle: string;
|
||||
onTitleChange: (text: string) => void;
|
||||
bookAuthor: string;
|
||||
onAuthorChange: (text: string) => void;
|
||||
totalPages: string;
|
||||
onPagesChange: (text: string) => void;
|
||||
loading: boolean;
|
||||
onAddBook: () => void;
|
||||
}
|
||||
|
||||
export default function AddBookModal({
|
||||
visible,
|
||||
onClose,
|
||||
bookTitle,
|
||||
onTitleChange,
|
||||
bookAuthor,
|
||||
onAuthorChange,
|
||||
totalPages,
|
||||
onPagesChange,
|
||||
loading,
|
||||
onAddBook,
|
||||
}: AddBookModalProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
maxHeight: "90%",
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: ROUNDNESS.lg,
|
||||
borderTopRightRadius: ROUNDNESS.lg,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
modalField: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
modalLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
color: colors.outline,
|
||||
marginBottom: SPACING.sm,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
modalInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.surfaceVariant,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.md,
|
||||
fontSize: 14,
|
||||
color: colors.onSurface,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
gap: SPACING.sm,
|
||||
marginHorizontal: SPACING.lg,
|
||||
marginVertical: SPACING.md,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.modalContainer}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Add New Book</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.modalField}>
|
||||
<Text style={styles.modalLabel}>TITLE</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={bookTitle}
|
||||
onChangeText={onTitleChange}
|
||||
placeholder="Book Title"
|
||||
placeholderTextColor={colors.outline}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.modalField}>
|
||||
<Text style={styles.modalLabel}>AUTHOR</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={bookAuthor}
|
||||
onChangeText={onAuthorChange}
|
||||
placeholder="Author Name"
|
||||
placeholderTextColor={colors.outline}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.modalField}>
|
||||
<Text style={styles.modalLabel}>TOTAL PAGES</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={totalPages}
|
||||
onChangeText={onPagesChange}
|
||||
keyboardType="numeric"
|
||||
placeholder="Total pages"
|
||||
placeholderTextColor={colors.outline}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, loading && { opacity: 0.6 }]}
|
||||
onPress={onAddBook}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<>
|
||||
<Upload size={20} color={colors.onPrimary} />
|
||||
<Text style={styles.primaryBtnText}>Add to Library</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Book as BookIcon, Play } from "lucide-react-native";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
status: "reading" | "finished" | "want_to_read";
|
||||
}
|
||||
|
||||
interface BookCardProps {
|
||||
book: Book;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export default function BookCard({ book, onPress }: BookCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
bookCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
overflow: "hidden",
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
bookCover: {
|
||||
height: 160,
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
},
|
||||
resumeIndicator: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: colors.primary,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
progressOverlay: {
|
||||
position: "absolute",
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
bookTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: colors.onSurface,
|
||||
paddingHorizontal: SPACING.sm,
|
||||
paddingTop: SPACING.sm,
|
||||
},
|
||||
bookAuthor: {
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
paddingHorizontal: SPACING.sm,
|
||||
},
|
||||
progressBarBg: {
|
||||
height: 3,
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
marginHorizontal: SPACING.sm,
|
||||
marginVertical: SPACING.sm,
|
||||
borderRadius: 1.5,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBarFill: {
|
||||
height: "100%",
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
const progress =
|
||||
book.total_pages > 0
|
||||
? Math.round((book.current_page / book.total_pages) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.bookCard} onPress={onPress}>
|
||||
<View style={styles.bookCover}>
|
||||
<BookIcon size={40} color={colors.outlineVariant} strokeWidth={1} />
|
||||
{book.status === "reading" && (
|
||||
<View style={styles.resumeIndicator}>
|
||||
<Play size={12} color="#fff" fill="#fff" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.progressOverlay}>
|
||||
<Text style={styles.progressText}>{progress}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.bookTitle} numberOfLines={1}>
|
||||
{book.title}
|
||||
</Text>
|
||||
<Text style={styles.bookAuthor} numberOfLines={1}>
|
||||
{book.author}
|
||||
</Text>
|
||||
|
||||
<View style={styles.progressBarBg}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBarFill,
|
||||
{
|
||||
width: `${Math.min(progress, 100)}%`,
|
||||
backgroundColor:
|
||||
book.status === "finished" ? colors.tertiary : colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import { ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { useData } from "@/src/hooks/useData";
|
||||
import {
|
||||
Book as BookIcon,
|
||||
Clock,
|
||||
Play,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
X,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
} from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
status: "reading" | "finished" | "want_to_read";
|
||||
file_uri: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ReadingLog {
|
||||
id: string;
|
||||
start_page: number;
|
||||
end_page: number;
|
||||
pages_read: number;
|
||||
duration_seconds: number;
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
interface BookDetailsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
book: Book | null;
|
||||
isGeneratingAI: boolean;
|
||||
onContinueReading: () => void;
|
||||
onGenerateAI: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function BookDetailsModal({
|
||||
visible,
|
||||
onClose,
|
||||
book,
|
||||
isGeneratingAI,
|
||||
onContinueReading,
|
||||
onGenerateAI,
|
||||
onDelete,
|
||||
}: BookDetailsModalProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
const { data: logs, loading: logsLoading } = useData<ReadingLog>(
|
||||
"SELECT * FROM reading_logs WHERE book_id = ? ORDER BY logged_at DESC LIMIT 5",
|
||||
[book?.id || ""],
|
||||
);
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
if (mins < 1) return "< 1 min";
|
||||
return `${mins} min${mins > 1 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Book Details</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{book && (
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||
<View style={styles.detailsHeader}>
|
||||
<View style={[styles.bookCover, { width: 120, height: 160 }]}>
|
||||
<BookIcon size={48} color={colors.outlineVariant} />
|
||||
</View>
|
||||
<View style={styles.detailsMeta}>
|
||||
<Text style={styles.detailsTitle}>{book.title}</Text>
|
||||
<Text style={styles.detailsAuthor}>{book.author}</Text>
|
||||
<View style={styles.detailsStats}>
|
||||
<View style={styles.detailStatItem}>
|
||||
<TrendingUp size={14} color={colors.primary} />
|
||||
<Text style={styles.detailStatText}>
|
||||
{book.current_page} / {book.total_pages} pages
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailStatItem}>
|
||||
<Clock size={14} color={colors.secondary} />
|
||||
<Text style={styles.detailStatText}>
|
||||
Last read:{" "}
|
||||
{new Date(book.updated_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { marginBottom: 12 }]}
|
||||
onPress={onContinueReading}
|
||||
>
|
||||
<Play size={20} color={colors.onPrimary} fill="#fff" />
|
||||
<Text style={styles.primaryBtnText}>Continue Reading</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Reading History Section */}
|
||||
<View style={styles.historySection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Reading History</Text>
|
||||
<TrendingUp size={16} color={colors.outline} />
|
||||
</View>
|
||||
|
||||
{logsLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : logs.length > 0 ? (
|
||||
<View style={styles.logsList}>
|
||||
{logs.map((log) => (
|
||||
<View key={log.id} style={styles.logItem}>
|
||||
<View style={styles.logLeft}>
|
||||
<View style={styles.dateChip}>
|
||||
<Calendar size={10} color={colors.primary} />
|
||||
<Text style={styles.logDateText}>{formatDate(log.logged_at)}</Text>
|
||||
</View>
|
||||
<Text style={styles.logPagesText}>
|
||||
p. {log.start_page} → {log.end_page}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.logRight}>
|
||||
<Text style={styles.logDurationText}>
|
||||
{formatDuration(log.duration_seconds)}
|
||||
</Text>
|
||||
<Text style={styles.logDeltaText}>+{log.pages_read} pages</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyLogsText}>No reading sessions logged yet.</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actionSection}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.primaryBtn,
|
||||
{
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
]}
|
||||
onPress={onGenerateAI}
|
||||
disabled={isGeneratingAI}
|
||||
>
|
||||
{isGeneratingAI ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<Sparkles size={20} color={colors.primary} />
|
||||
)}
|
||||
<Text
|
||||
style={[styles.primaryBtnText, { color: colors.onSurface }]}
|
||||
>
|
||||
Generate AI Insights
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteActionBtn}
|
||||
onPress={onDelete}
|
||||
>
|
||||
<Trash2 size={18} color={colors.error} />
|
||||
<Text style={styles.deleteActionText}>Remove from Library</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
maxHeight: "90%",
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: ROUNDNESS.lg,
|
||||
borderTopRightRadius: ROUNDNESS.lg,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
detailsHeader: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.lg,
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
bookCover: {
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
detailsMeta: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
detailsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
detailsAuthor: {
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
detailsStats: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
detailStatItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
detailStatText: {
|
||||
fontSize: 11,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
gap: SPACING.sm,
|
||||
marginHorizontal: SPACING.lg,
|
||||
marginVertical: SPACING.md,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
historySection: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
marginVertical: SPACING.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
logsList: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
logItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surfaceVariant + "40",
|
||||
padding: SPACING.md,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "20",
|
||||
},
|
||||
logLeft: {
|
||||
gap: 4,
|
||||
},
|
||||
dateChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: colors.primary + "15",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
logDateText: {
|
||||
fontSize: 9,
|
||||
fontWeight: "600",
|
||||
color: colors.primary,
|
||||
},
|
||||
logPagesText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
logRight: {
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
logDurationText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
logDeltaText: {
|
||||
fontSize: 10,
|
||||
color: colors.outline,
|
||||
},
|
||||
emptyLogsText: {
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
marginVertical: SPACING.md,
|
||||
},
|
||||
actionSection: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
marginTop: SPACING.lg,
|
||||
gap: SPACING.md,
|
||||
},
|
||||
deleteActionBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
marginBottom: SPACING.lg,
|
||||
gap: SPACING.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.error + "30",
|
||||
borderRadius: ROUNDNESS.md,
|
||||
},
|
||||
deleteActionText: {
|
||||
color: colors.error,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import BookCard from "./BookCard";
|
||||
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
status: "reading" | "finished" | "want_to_read";
|
||||
file_uri: string;
|
||||
cover_uri: string;
|
||||
}
|
||||
|
||||
interface BookGridProps {
|
||||
books: Book[];
|
||||
loading: boolean;
|
||||
onBookPress: (book: Book) => void;
|
||||
onEmptyPress: () => void;
|
||||
}
|
||||
|
||||
export default function BookGrid({
|
||||
books,
|
||||
loading,
|
||||
onBookPress,
|
||||
onEmptyPress,
|
||||
}: BookGridProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
bookGrid: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
gap: SPACING.md,
|
||||
},
|
||||
emptyBookCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surfaceVariant + "40",
|
||||
borderStyle: "dashed",
|
||||
borderWidth: 2,
|
||||
borderColor: colors.outline + "40",
|
||||
borderRadius: 12,
|
||||
padding: SPACING.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 200,
|
||||
gap: SPACING.md,
|
||||
},
|
||||
emptyBookText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
emptyBookSub: {
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.bookGrid, { alignItems: "center", marginTop: 40 }]}>
|
||||
<ActivityIndicator color={colors.primary} size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.bookGrid}>
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} onPress={() => onBookPress(book)} />
|
||||
))}
|
||||
{books.length === 0 && (
|
||||
<TouchableOpacity style={styles.emptyBookCard} onPress={onEmptyPress}>
|
||||
<Upload size={32} color={colors.outline} strokeWidth={1.5} />
|
||||
<Text style={styles.emptyBookText}>Your library is empty</Text>
|
||||
<Text style={styles.emptyBookSub}>Tap to add your first book</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Menu, Settings } from "lucide-react-native";
|
||||
import React from "react";
|
||||
import { Image, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
onSettingsPress?: () => void;
|
||||
onMenuPress?: () => void;
|
||||
}
|
||||
|
||||
export default function LibraryHeader({
|
||||
onSettingsPress,
|
||||
onMenuPress,
|
||||
}: LibraryHeaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
backgroundColor: colors.background,
|
||||
height: 60,
|
||||
},
|
||||
menuBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
logoContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: -1,
|
||||
},
|
||||
logoImage: {
|
||||
height: 40,
|
||||
width: 160,
|
||||
},
|
||||
ghostBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuBtn}
|
||||
onPress={onMenuPress || (() => router.push("/menu"))}
|
||||
>
|
||||
<Menu size={24} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("@/assets/images/Artboard 1 logo.png")}
|
||||
style={styles.logoImage}
|
||||
tintColor={colors.primary}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.ghostBtn}
|
||||
onPress={onSettingsPress || (() => router.push("/modal"))}
|
||||
>
|
||||
<Settings size={20} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { BookOpen, CheckCircle2, TrendingUp } from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
interface LibraryStatsProps {
|
||||
stats: {
|
||||
reading: number;
|
||||
finished: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function LibraryStats({ stats }: LibraryStatsProps) {
|
||||
const { colors, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors, isDark), [colors, isDark]);
|
||||
|
||||
return (
|
||||
<View style={styles.statsSection}>
|
||||
<View style={styles.statCard}>
|
||||
<TrendingUp size={18} color={colors.primary} />
|
||||
<Text style={styles.statValue}>{stats.pages}</Text>
|
||||
<Text style={styles.statLabel}>PAGES READ</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<BookOpen size={18} color={colors.secondary} />
|
||||
<Text style={styles.statValue}>{stats.reading}</Text>
|
||||
<Text style={styles.statLabel}>ACTIVE</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<CheckCircle2 size={18} color={colors.tertiary} />
|
||||
<Text style={styles.statValue}>{stats.finished}</Text>
|
||||
<Text style={styles.statLabel}>FINISHED</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
statsSection: {
|
||||
flexDirection: "row",
|
||||
gap: SPACING.md,
|
||||
marginVertical: SPACING.lg,
|
||||
paddingHorizontal: SPACING.lg,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: 12,
|
||||
padding: SPACING.md,
|
||||
alignItems: "center",
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 9,
|
||||
fontWeight: "600",
|
||||
color: colors.outline,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { ROUNDNESS, SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Save, X } from "lucide-react-native";
|
||||
import React from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface NoteEditorModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentPage: number;
|
||||
currentNote: string;
|
||||
onNoteChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export default function NoteEditorModal({
|
||||
visible,
|
||||
onClose,
|
||||
currentPage,
|
||||
currentNote,
|
||||
onNoteChange,
|
||||
onSave,
|
||||
isEditing,
|
||||
}: NoteEditorModalProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
maxHeight: "90%",
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: ROUNDNESS.lg,
|
||||
borderTopRightRadius: ROUNDNESS.lg,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
modalField: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
noteInput: {
|
||||
minHeight: 120,
|
||||
textAlignVertical: "top",
|
||||
paddingTop: SPACING.md,
|
||||
},
|
||||
modalInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.surfaceVariant,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.md,
|
||||
fontSize: 14,
|
||||
color: colors.onSurface,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
gap: SPACING.sm,
|
||||
marginHorizontal: SPACING.lg,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.modalContainer}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{isEditing ? "Edit Note" : `Add Note - Page ${currentPage}`}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.modalField}>
|
||||
<TextInput
|
||||
style={[styles.modalInput, styles.noteInput]}
|
||||
value={currentNote}
|
||||
onChangeText={onNoteChange}
|
||||
placeholder="Write your note here..."
|
||||
placeholderTextColor={colors.outline}
|
||||
multiline
|
||||
numberOfLines={5}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.primaryBtn} onPress={onSave}>
|
||||
<Save size={20} color={colors.onPrimary} />
|
||||
<Text style={styles.primaryBtnText}>
|
||||
{isEditing ? "Update Note" : "Save Note"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { SPACING } from "@/src/constants/Theme";
|
||||
import { useTheme } from "@/src/hooks/useTheme";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
page: number;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface NotesPanelProps {
|
||||
currentPage: number;
|
||||
notes: Note[];
|
||||
animatedStyle: any;
|
||||
onAddNote: () => void;
|
||||
onEditNote: (note: Note) => void;
|
||||
onDeleteNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export default function NotesPanel({
|
||||
currentPage,
|
||||
notes,
|
||||
animatedStyle,
|
||||
onAddNote,
|
||||
onEditNote,
|
||||
onDeleteNote,
|
||||
}: NotesPanelProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
const currentPageNotes = notes.filter((note) => note.page === currentPage);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.notesPanel, animatedStyle]}>
|
||||
<View style={styles.notesHeader}>
|
||||
<Text style={styles.notesTitle}>Notes for Page {currentPage}</Text>
|
||||
<TouchableOpacity onPress={onAddNote}>
|
||||
<Plus size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={styles.notesList}>
|
||||
{currentPageNotes.map((note) => (
|
||||
<View key={note.id} style={styles.noteItem}>
|
||||
<View style={styles.noteContent}>
|
||||
<Text style={styles.noteText}>{note.content}</Text>
|
||||
<Text style={styles.noteTimestamp}>
|
||||
{new Date(note.timestamp).toLocaleTimeString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.noteActions}>
|
||||
<TouchableOpacity onPress={() => onEditNote(note)}>
|
||||
<Pencil size={16} color={colors.outline} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => onDeleteNote(note.id)}>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{currentPageNotes.length === 0 && (
|
||||
<Text style={styles.noNotesText}>No notes for this page</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
notesPanel: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.surfaceVariant,
|
||||
zIndex: 50,
|
||||
},
|
||||
notesHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
notesTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: colors.onSurface,
|
||||
},
|
||||
notesList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
noteItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
noteContent: {
|
||||
flex: 1,
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
noteText: {
|
||||
fontSize: 13,
|
||||
color: colors.onSurface,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
noteTimestamp: {
|
||||
fontSize: 10,
|
||||
color: colors.outline,
|
||||
},
|
||||
noteActions: {
|
||||
flexDirection: "row",
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
noNotesText: {
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
textAlign: "center",
|
||||
paddingVertical: SPACING.lg,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
} from 'react-native-reanimated';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import {
|
||||
X,
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
ArrowRight
|
||||
} from 'lucide-react-native';
|
||||
import { ROUNDNESS, SPACING } from '@/src/constants/Theme';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
|
||||
import Constants, { ExecutionEnvironment } from 'expo-constants';
|
||||
|
||||
import { WebView } from 'react-native-webview';
|
||||
import PDFReader from '@bildau/rn-pdf-reader';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
// Safe PDF Component Wrapper
|
||||
const PdfRendererComponent = React.memo((props: any) => {
|
||||
const { source, style, onLoad, onPageChange, page } = props;
|
||||
const [base64Source, setBase64Source] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSource = async () => {
|
||||
if (Platform.OS === 'android' && source.startsWith('file://')) {
|
||||
try {
|
||||
console.log('[PdfReader] Android local file detected, reading as base64...');
|
||||
const base64 = await FileSystem.readAsStringAsync(source, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (isMounted) {
|
||||
setBase64Source(`data:application/pdf;base64,${base64}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[PdfReader] Failed to read local file as base64:', e);
|
||||
if (isMounted) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isMounted) {
|
||||
setBase64Source(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSource();
|
||||
return () => { isMounted = false; };
|
||||
}, [source]);
|
||||
|
||||
// Memoize source object to avoid triggering PDFReader's componentDidUpdate unnecessarily
|
||||
const memoizedSource = useMemo(() => {
|
||||
if (base64Source) {
|
||||
return { base64: base64Source };
|
||||
}
|
||||
return { uri: source };
|
||||
}, [source, base64Source]);
|
||||
|
||||
if (Platform.OS === 'android' && source.startsWith('file://') && !base64Source && !error) {
|
||||
return (
|
||||
<View style={[style, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PDFReader
|
||||
source={memoizedSource}
|
||||
style={style}
|
||||
withScroll={true}
|
||||
withPinchZoom={true}
|
||||
noLoader={true}
|
||||
customStyle={{
|
||||
readerContainerNavigate: { display: 'none' },
|
||||
readerContainerNumbers: { display: 'none' },
|
||||
}}
|
||||
onLoad={(event: any) => {
|
||||
// The library doesn't easily expose total pages here,
|
||||
// but we can signal it's loaded.
|
||||
onLoad?.(0);
|
||||
}}
|
||||
webviewProps={{
|
||||
onMessage: (event: any) => {
|
||||
try {
|
||||
const data = JSON.parse(event.nativeEvent.data);
|
||||
if (data.type === 'pageChange') {
|
||||
onPageChange?.(data.page - 1, data.total);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
interface PdfReaderProps {
|
||||
uri: string;
|
||||
title: string;
|
||||
initialPage?: number;
|
||||
onClose: () => void;
|
||||
onPageChange?: (page: number, total: number) => void;
|
||||
onSessionUpdate?: (seconds: number) => void;
|
||||
onAddNote?: (page: number) => void;
|
||||
}
|
||||
|
||||
export const PdfReader: React.FC<PdfReaderProps> = ({
|
||||
uri,
|
||||
title,
|
||||
initialPage = 0,
|
||||
onClose,
|
||||
onPageChange,
|
||||
onSessionUpdate,
|
||||
onAddNote,
|
||||
}) => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// State
|
||||
const [currentPage, setCurrentPage] = useState(initialPage);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [sessionSeconds, setSessionSeconds] = useState(0);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(true);
|
||||
const [jumpToPage, setJumpToPage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Animations
|
||||
const controlsOpacity = useSharedValue(1);
|
||||
const topBarY = useSharedValue(0);
|
||||
const bottomBarY = useSharedValue(0);
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
if (isTimerRunning) {
|
||||
interval = setInterval(() => {
|
||||
setSessionSeconds(s => s + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [isTimerRunning]);
|
||||
|
||||
// Report session time to parent safely
|
||||
useEffect(() => {
|
||||
onSessionUpdate?.(sessionSeconds);
|
||||
}, [sessionSeconds]);
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (controlsVisible) {
|
||||
timer = setTimeout(() => {
|
||||
toggleControls(false);
|
||||
}, 5000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [controlsVisible]);
|
||||
|
||||
const toggleControls = useCallback((force?: boolean) => {
|
||||
const nextVisible = force !== undefined ? force : !controlsVisible;
|
||||
setControlsVisible(nextVisible);
|
||||
|
||||
const targetOpacity = nextVisible ? 1 : 0;
|
||||
const targetY = nextVisible ? 0 : -100;
|
||||
const targetBottomY = nextVisible ? 0 : 100;
|
||||
|
||||
controlsOpacity.value = withTiming(targetOpacity);
|
||||
topBarY.value = withTiming(targetY);
|
||||
bottomBarY.value = withTiming(targetBottomY);
|
||||
}, [controlsVisible]);
|
||||
|
||||
const animatedTopBarStyle = useAnimatedStyle(() => ({
|
||||
opacity: controlsOpacity.value,
|
||||
transform: [{ translateY: topBarY.value }],
|
||||
}));
|
||||
|
||||
const animatedBottomBarStyle = useAnimatedStyle(() => ({
|
||||
opacity: controlsOpacity.value,
|
||||
transform: [{ translateY: bottomBarY.value }],
|
||||
}));
|
||||
|
||||
const formatTime = (totalSeconds: number) => {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handlePageChange = useCallback((page: number, total: number) => {
|
||||
setCurrentPage(page);
|
||||
setTotalPages(total);
|
||||
// Use a small delay to avoid "update while rendering" if called synchronously by PdfRenderer
|
||||
setTimeout(() => {
|
||||
onPageChange?.(page, total);
|
||||
}, 0);
|
||||
}, [onPageChange]);
|
||||
|
||||
const handleLoad = useCallback((total: number) => {
|
||||
setTotalPages(total);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleGoToPage = () => {
|
||||
const page = parseInt(jumpToPage);
|
||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
||||
// In react-native-pdf-renderer, we might need a ref to the component to scroll
|
||||
// For now, we update the state and assume the component reacts to it or we'll add a ref later
|
||||
setCurrentPage(page - 1);
|
||||
setJumpToPage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#000' }]}>
|
||||
{/* PDF View */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={() => toggleControls()}
|
||||
style={styles.pdfContainer}
|
||||
>
|
||||
<PdfRendererComponent
|
||||
source={uri}
|
||||
style={styles.pdf}
|
||||
onLoad={handleLoad}
|
||||
onPageChange={handlePageChange}
|
||||
page={currentPage}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Top Controls */}
|
||||
<Animated.View style={[styles.topBarContainer, animatedTopBarStyle, { top: insets.top + SPACING.sm }]}>
|
||||
<BlurView intensity={isDark ? 40 : 60} tint={isDark ? 'dark' : 'light'} style={styles.glassBar}>
|
||||
<View style={styles.topBarContent}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.iconButton}>
|
||||
<X size={24} color={isDark ? '#fff' : '#000'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.bookTitle, { color: isDark ? '#fff' : '#000' }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.pageText}>
|
||||
{currentPage + 1} / {totalPages}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timerChip}>
|
||||
<Clock size={14} color={colors.primary} />
|
||||
<Text style={[styles.timerText, { color: colors.primary }]}>
|
||||
{formatTime(sessionSeconds)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onAddNote?.(currentPage)}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MessageSquare size={22} color={isDark ? '#fff' : '#000'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<Animated.View style={[styles.bottomBarContainer, animatedBottomBarStyle, { bottom: insets.bottom + SPACING.md }]}>
|
||||
<BlurView intensity={isDark ? 40 : 60} tint={isDark ? 'dark' : 'light'} style={styles.glassBar}>
|
||||
<View style={styles.bottomBarContent}>
|
||||
<View style={styles.jumpContainer}>
|
||||
<TextInput
|
||||
style={[styles.pageInput, { color: isDark ? '#fff' : '#000', borderColor: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)' }]}
|
||||
placeholder="Page"
|
||||
placeholderTextColor={isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
keyboardType="numeric"
|
||||
value={jumpToPage}
|
||||
onChangeText={setJumpToPage}
|
||||
onSubmitEditing={handleGoToPage}
|
||||
/>
|
||||
<TouchableOpacity onPress={handleGoToPage} style={[styles.goButton, { backgroundColor: colors.primary }]}>
|
||||
<ArrowRight size={18} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsTimerRunning(!isTimerRunning)}
|
||||
style={[styles.sessionButton, { backgroundColor: isTimerRunning ? colors.secondaryContainer : colors.primary }]}
|
||||
>
|
||||
{isTimerRunning ? (
|
||||
<>
|
||||
<Pause size={18} color={colors.onSecondaryContainer} />
|
||||
<Text style={[styles.sessionButtonText, { color: colors.onSecondaryContainer }]}>Pause Session</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={18} color="#fff" />
|
||||
<Text style={[styles.sessionButtonText, { color: '#fff' }]}>Resume Session</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
pdfContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
pdf: {
|
||||
flex: 1,
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topBarContainer: {
|
||||
position: 'absolute',
|
||||
left: SPACING.md,
|
||||
right: SPACING.md,
|
||||
zIndex: 1000,
|
||||
},
|
||||
bottomBarContainer: {
|
||||
position: 'absolute',
|
||||
left: SPACING.md,
|
||||
right: SPACING.md,
|
||||
zIndex: 1000,
|
||||
},
|
||||
glassBar: {
|
||||
borderRadius: ROUNDNESS.full,
|
||||
overflow: 'hidden',
|
||||
padding: SPACING.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
topBarContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: SPACING.sm,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: SPACING.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
bookTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
pageText: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(128,128,128,0.8)',
|
||||
marginTop: 2,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
timerChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
timerText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
},
|
||||
bottomBarContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: SPACING.sm,
|
||||
},
|
||||
jumpContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
pageInput: {
|
||||
width: 60,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
goButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
sessionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 25,
|
||||
},
|
||||
sessionButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default PdfReader;
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View, Image, Dimensions, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSequence,
|
||||
withDelay,
|
||||
runOnJS,
|
||||
Easing
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface Props {
|
||||
onAnimationFinish: () => void;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export function AnimatedSplashScreen({ onAnimationFinish, backgroundColor }: Props) {
|
||||
const scale = useSharedValue(0.3);
|
||||
const opacity = useSharedValue(0);
|
||||
const footerOpacity = useSharedValue(0);
|
||||
const containerOpacity = useSharedValue(1);
|
||||
|
||||
const animatedLogoStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
return {
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const animatedFooterStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
return {
|
||||
opacity: footerOpacity.value,
|
||||
transform: [{ translateY: withTiming(footerOpacity.value === 1 ? 0 : 20, { duration: 800 }) }],
|
||||
};
|
||||
});
|
||||
|
||||
const animatedContainerStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
return {
|
||||
opacity: containerOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Sequence: Fade in and scale up -> Hold -> Zoom in/Fade out container
|
||||
scale.value = withTiming(1, {
|
||||
duration: 1000,
|
||||
easing: Easing.out(Easing.back(1.5))
|
||||
});
|
||||
opacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
// Fade in footer slightly later
|
||||
footerOpacity.value = withDelay(400, withTiming(1, { duration: 800 }));
|
||||
|
||||
containerOpacity.value = withDelay(
|
||||
2500,
|
||||
withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(onAnimationFinish)();
|
||||
}
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { backgroundColor }, animatedContainerStyle]}>
|
||||
<View style={styles.content}>
|
||||
<Animated.Image
|
||||
source={require('@/assets/images/icon.png')}
|
||||
style={[styles.logo, animatedLogoStyle]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.footer, animatedFooterStyle]}>
|
||||
<Text style={styles.poweredBy}>powered by</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/Artboard 1 logo.png')}
|
||||
style={styles.footerLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: width * 0.45,
|
||||
height: width * 0.45,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 50,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
poweredBy: {
|
||||
fontSize: 12,
|
||||
color: '#94a3b8',
|
||||
textTransform: 'lowercase',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'Manrope_400Regular',
|
||||
},
|
||||
footerLogo: {
|
||||
width: width * 0.3,
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { SPACING, ThemeColors } from "@/src/constants/Theme";
|
||||
import { Menu, Settings } from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import { Image, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
colors: ThemeColors;
|
||||
onMenuPress: () => void;
|
||||
onSettingsPress: () => void;
|
||||
}
|
||||
|
||||
const ChatHeader = ({
|
||||
colors,
|
||||
onMenuPress,
|
||||
onSettingsPress,
|
||||
}: ChatHeaderProps) => {
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={onMenuPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open menu"
|
||||
>
|
||||
<Menu size={24} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("@/assets/images/Artboard 1 logo.png")}
|
||||
style={[styles.logoImage, { tintColor: colors.primary }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={onSettingsPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open settings"
|
||||
>
|
||||
<Settings size={20} color={colors.primary} strokeWidth={1.5} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (_colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
height: 60,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
},
|
||||
logoContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: -1,
|
||||
},
|
||||
logoImage: {
|
||||
height: 40,
|
||||
width: 160,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ChatHeader);
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { FONTS, ROUNDNESS, SPACING, ThemeColors } from "@/src/constants/Theme";
|
||||
import { Send } from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface ChatInputProps {
|
||||
inputText: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSend: (text: string) => void;
|
||||
placeholder: string;
|
||||
colors: ThemeColors;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ChatInput = ({
|
||||
inputText,
|
||||
onChangeText,
|
||||
onSend,
|
||||
placeholder,
|
||||
colors,
|
||||
disabled = false,
|
||||
}: ChatInputProps) => {
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (inputText.trim() && !disabled) {
|
||||
onSend(inputText);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.inputSection}>
|
||||
<View style={styles.inputBarContainer}>
|
||||
<TextInput
|
||||
style={[styles.input, disabled && { opacity: 0.5 }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.onSurfaceVariant + "80"}
|
||||
value={inputText}
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={handleSend}
|
||||
returnKeyType="send"
|
||||
blurOnSubmit={false}
|
||||
multiline={false}
|
||||
editable={!disabled}
|
||||
accessibilityLabel="Chat input"
|
||||
accessibilityHint="Type a message for the Habit Architect"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sendButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
disabled && { backgroundColor: colors.outlineVariant, opacity: 0.7 },
|
||||
]}
|
||||
onPress={handleSend}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Send message"
|
||||
>
|
||||
<Send size={18} color={disabled ? colors.onSurfaceVariant : colors.onPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
inputSection: {
|
||||
backgroundColor: colors.surface,
|
||||
paddingBottom: Platform.OS === "ios" ? 10 : 20,
|
||||
},
|
||||
inputBarContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surface,
|
||||
marginHorizontal: SPACING.lg,
|
||||
borderRadius: ROUNDNESS.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "80",
|
||||
padding: 4,
|
||||
marginBottom: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
paddingHorizontal: 16,
|
||||
height: 48,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ChatInput);
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import TypingIndicator from "@/src/components/chat/TypingIndicator";
|
||||
import { FONTS, ROUNDNESS, SPACING, ThemeColors } from "@/src/constants/Theme";
|
||||
import { ChatMessage } from "@/src/types/chat";
|
||||
import { formatTime } from "@/src/utils/formatTime";
|
||||
import { CheckCircle2 } from "lucide-react-native";
|
||||
import React, { useMemo } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
isUser: boolean;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
const MessageBubble = ({ message, isUser, colors }: MessageBubbleProps) => {
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const actionLabel = message.action?.replace(/_/g, " ");
|
||||
const isSending = message.status === "sending";
|
||||
const hasError = message.status === "error";
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.messageWrapper,
|
||||
isUser ? styles.userMessageWrapper : styles.aiMessageWrapper,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
isUser ? styles.userBubble : styles.aiBubble,
|
||||
]}
|
||||
>
|
||||
{isSending ? (
|
||||
<TypingIndicator colors={colors} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.messageText,
|
||||
isUser ? styles.userText : styles.aiText,
|
||||
]}
|
||||
>
|
||||
{message.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{actionLabel ? (
|
||||
<View style={styles.actionBadge}>
|
||||
<CheckCircle2
|
||||
size={12}
|
||||
color={isUser ? colors.onPrimary : colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
{ color: isUser ? colors.onPrimary : colors.primary },
|
||||
]}
|
||||
>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText} accessibilityRole="text">
|
||||
Unable to generate response. Tap send again.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.messageTime}>{formatTime(message.createdAt)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
messageWrapper: {
|
||||
marginBottom: SPACING.lg,
|
||||
maxWidth: "85%",
|
||||
},
|
||||
aiMessageWrapper: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
userMessageWrapper: {
|
||||
alignSelf: "flex-end",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
messageBubble: {
|
||||
padding: 14,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
},
|
||||
aiBubble: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
borderTopLeftRadius: 4,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: colors.primary,
|
||||
borderTopRightRadius: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
aiText: {
|
||||
color: colors.onSurface,
|
||||
},
|
||||
userText: {
|
||||
color: colors.onPrimary,
|
||||
},
|
||||
actionBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
marginTop: 8,
|
||||
backgroundColor: "rgba(0,0,0,0.05)",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
actionText: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
messageTime: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 9,
|
||||
color: colors.onSurfaceVariant,
|
||||
marginTop: 6,
|
||||
},
|
||||
errorText: {
|
||||
marginTop: 10,
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 11,
|
||||
color: colors.error,
|
||||
},
|
||||
});
|
||||
|
||||
const areEqual = (prev: MessageBubbleProps, next: MessageBubbleProps) =>
|
||||
prev.message.id === next.message.id &&
|
||||
prev.message.content === next.message.content &&
|
||||
prev.message.status === next.message.status &&
|
||||
prev.message.action === next.message.action &&
|
||||
prev.isUser === next.isUser &&
|
||||
prev.colors.primary === next.colors.primary &&
|
||||
prev.colors.surface === next.colors.surface &&
|
||||
prev.colors.onSurface === next.colors.onSurface;
|
||||
|
||||
export default React.memo(MessageBubble, areEqual);
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { FONTS, ROUNDNESS, SPACING, ThemeColors } from "@/src/constants/Theme";
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
const suggestions = [
|
||||
"Audit my habits",
|
||||
"Suggest a 2-min version",
|
||||
"Log my workout",
|
||||
"Help me build a morning routine",
|
||||
] as const;
|
||||
|
||||
interface SuggestionChipsProps {
|
||||
colors: ThemeColors;
|
||||
onSuggestionPress: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SuggestionChips = ({
|
||||
colors,
|
||||
onSuggestionPress,
|
||||
disabled = false,
|
||||
}: SuggestionChipsProps) => {
|
||||
const styles = createStyles(colors);
|
||||
|
||||
const renderSuggestion = useCallback(
|
||||
({ item }: { item: string }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.suggestionButton, disabled && { opacity: 0.5 }]}
|
||||
onPress={() => !disabled && onSuggestionPress(item)}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Send suggestion: ${item}`}
|
||||
>
|
||||
<Text style={styles.suggestionText}>{item}</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
[onSuggestionPress, styles, disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={suggestions}
|
||||
keyExtractor={(item) => item}
|
||||
renderItem={renderSuggestion}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
ItemSeparatorComponent={() => <View style={styles.suggestionSpacer} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
},
|
||||
suggestionSpacer: {
|
||||
width: SPACING.sm,
|
||||
},
|
||||
suggestionButton: {
|
||||
backgroundColor: colors.surfaceVariant + "80",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "33",
|
||||
},
|
||||
suggestionText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(SuggestionChips);
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { FONTS, ROUNDNESS, ThemeColors } from "@/src/constants/Theme";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
const TypingIndicator = ({ colors }: TypingIndicatorProps) => {
|
||||
const [dots, setDots] = useState(1);
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDots((value) => (value % 3) + 1);
|
||||
}, 420);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityRole="text">
|
||||
<Text
|
||||
style={styles.text}
|
||||
>{`Architecting response${".".repeat(dots)}`}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
wrapper: {
|
||||
backgroundColor: colors.surface,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: ROUNDNESS.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.outlineVariant + "4D",
|
||||
},
|
||||
text: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: colors.onSurface,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(TypingIndicator);
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, Platform, FlatList } from 'react-native';
|
||||
import { FONTS, SPACING, ROUNDNESS } from '@/src/constants/Theme';
|
||||
import { getLocalDateString } from '@/src/lib/date-utils';
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Check } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface CalendarStripProps {
|
||||
selectedDate: string; // YYYY-MM-DD
|
||||
onDateSelect: (date: string) => void;
|
||||
colors: any;
|
||||
}
|
||||
|
||||
export const CalendarStrip: React.FC<CalendarStripProps> = ({ selectedDate, onDateSelect, colors }) => {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
// Constants for scroll calculation
|
||||
const ITEM_WIDTH = 55;
|
||||
const GAP = 12;
|
||||
|
||||
// Scroll to selected date on mount and when selectedDate changes
|
||||
useEffect(() => {
|
||||
// We want the selected item (index 7 in our 15-day range) to be centered
|
||||
// Since we generate days centered around selectedDate, it's always at index 7
|
||||
const timer = setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
// Approximate centering logic
|
||||
// Each item + gap is ~67px. 7 items before = 469px.
|
||||
// We want to scroll so the 8th item is in the middle of the screen.
|
||||
scrollRef.current.scrollTo({
|
||||
x: (7 * (ITEM_WIDTH + GAP)) - 100, // Offset to bring it toward center
|
||||
animated: true
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Generate days centered around selectedDate
|
||||
const days = useMemo(() => {
|
||||
const result = [];
|
||||
const centerDate = new Date(selectedDate + 'T00:00:00'); // Ensure local time
|
||||
if (isNaN(centerDate.getTime())) return [];
|
||||
|
||||
const start = new Date(centerDate);
|
||||
start.setDate(centerDate.getDate() - 7);
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
const dateStr = getLocalDateString(d);
|
||||
result.push({
|
||||
date: dateStr,
|
||||
dayName: d.toLocaleDateString('en-US', { weekday: 'short' }),
|
||||
dayNum: d.getDate().toString(),
|
||||
isToday: dateStr === getLocalDateString(new Date()),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleDatePress = (date: string) => {
|
||||
onDateSelect(date);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const openPicker = () => {
|
||||
setShowPicker(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.stripWithButton}>
|
||||
<TouchableOpacity
|
||||
onPress={openPicker}
|
||||
style={[styles.pickerBtn, { backgroundColor: colors.surfaceVariant + '4D', borderColor: colors.outlineVariant + '33' }]}
|
||||
>
|
||||
<CalendarIcon size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
{days.map((day) => {
|
||||
const isSelected = day.date === selectedDate;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={day.date}
|
||||
onPress={() => handleDatePress(day.date)}
|
||||
activeOpacity={0.8}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
isSelected && { backgroundColor: colors.primary }
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
styles.dayName,
|
||||
isSelected ? { color: colors.onPrimary, fontFamily: FONTS.labelSm } : { color: colors.outline }
|
||||
]}>
|
||||
{day.dayName.toUpperCase()}
|
||||
</Text>
|
||||
|
||||
<View style={[
|
||||
styles.dayNumContainer,
|
||||
isSelected && { borderWidth: 1, borderColor: colors.onPrimary + '80' }
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.dayNum,
|
||||
isSelected ? { color: colors.onPrimary } : { color: colors.onSurface }
|
||||
]}>
|
||||
{day.dayNum}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{day.isToday && !isSelected && (
|
||||
<View style={[styles.todayIndicator, { backgroundColor: colors.primary }]} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<DatePickerModal
|
||||
visible={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={(date) => {
|
||||
onDateSelect(date);
|
||||
setShowPicker(false);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}}
|
||||
colors={colors}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const DatePickerModal = ({ visible, onClose, selectedDate, onSelect, colors }: any) => {
|
||||
const [viewDate, setViewDate] = useState(new Date(selectedDate + 'T00:00:00'));
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setViewDate(new Date(selectedDate + 'T00:00:00'));
|
||||
}
|
||||
}, [visible, selectedDate]);
|
||||
|
||||
const monthName = viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
const daysInMonth = useMemo(() => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const lastDate = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const days = [];
|
||||
// Padding for start of month
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
// Days of month
|
||||
for (let i = 1; i <= lastDate; i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
return days;
|
||||
}, [viewDate]);
|
||||
|
||||
const changeMonth = (offset: number) => {
|
||||
const next = new Date(viewDate);
|
||||
next.setMonth(viewDate.getMonth() + offset);
|
||||
setViewDate(next);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: colors.onSurface }]}>Select Date</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.monthNav}>
|
||||
<TouchableOpacity onPress={() => changeMonth(-1)} style={styles.navIconBtn}>
|
||||
<ChevronLeft size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.monthLabel, { color: colors.onSurface }]}>{monthName}</Text>
|
||||
<TouchableOpacity onPress={() => changeMonth(1)} style={styles.navIconBtn}>
|
||||
<ChevronRight size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.weekDaysRow}>
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => (
|
||||
<Text key={i} style={[styles.weekDayText, { color: colors.outline }]}>{d}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.calendarGrid}>
|
||||
{daysInMonth.map((date, i) => {
|
||||
if (!date) return <View key={i} style={styles.calendarDayCell} />;
|
||||
|
||||
const dateStr = getLocalDateString(date);
|
||||
const isSelected = dateStr === selectedDate;
|
||||
const isToday = dateStr === getLocalDateString(new Date());
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[
|
||||
styles.calendarDayCell,
|
||||
isSelected && { backgroundColor: colors.primary, borderRadius: 20 }
|
||||
]}
|
||||
onPress={() => onSelect(dateStr)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.calendarDayText,
|
||||
{ color: isSelected ? colors.onPrimary : colors.onSurface },
|
||||
isToday && !isSelected && { color: colors.primary, fontFamily: FONTS.labelSm }
|
||||
]}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.todayBtn, { borderColor: colors.primary + '4D' }]}
|
||||
onPress={() => onSelect(getLocalDateString(new Date()))}
|
||||
>
|
||||
<Text style={[styles.todayBtnText, { color: colors.primary }]}>GO TO TODAY</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outerContainer: {
|
||||
paddingVertical: SPACING.md,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
stripWithButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: SPACING.lg,
|
||||
},
|
||||
pickerBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
container: {
|
||||
paddingRight: SPACING.lg,
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dayCell: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 30,
|
||||
minWidth: 55,
|
||||
},
|
||||
dayName: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
dayNumContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 16,
|
||||
},
|
||||
dayNum: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 15,
|
||||
},
|
||||
todayIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
modalContent: {
|
||||
borderRadius: ROUNDNESS.xl,
|
||||
padding: SPACING.lg,
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
modalTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 20,
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
monthNav: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
backgroundColor: 'rgba(0,0,0,0.03)',
|
||||
borderRadius: ROUNDNESS.md,
|
||||
padding: 4,
|
||||
},
|
||||
navIconBtn: {
|
||||
padding: 10,
|
||||
},
|
||||
monthLabel: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 16,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
weekDaysRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
weekDayText: {
|
||||
width: '14.28%',
|
||||
textAlign: 'center',
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 12,
|
||||
},
|
||||
calendarGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
calendarDayCell: {
|
||||
width: '14.28%',
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
calendarDayText: {
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 15,
|
||||
},
|
||||
todayBtn: {
|
||||
borderWidth: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: ROUNDNESS.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
todayBtnText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1,
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
FlatList,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { Clock, X, Check } from 'lucide-react-native';
|
||||
import { FONTS, ROUNDNESS, SPACING } from '@/src/constants/Theme';
|
||||
import { useTheme } from '@/src/hooks/useTheme';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface TimeInputProps {
|
||||
value: string;
|
||||
onChange: (time: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const TimeInput: React.FC<TimeInputProps> = ({ value, onChange, label }) => {
|
||||
const { colors } = useTheme();
|
||||
const [tempValue, setTempValue] = useState(value);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTempValue(value);
|
||||
}, [value]);
|
||||
|
||||
const formatTime = (text: string) => {
|
||||
// Remove non-digits
|
||||
const cleaned = text.replace(/\D/g, '');
|
||||
|
||||
if (cleaned.length === 0) return '';
|
||||
|
||||
let hours = '00';
|
||||
let minutes = '00';
|
||||
|
||||
if (cleaned.length === 1) {
|
||||
hours = cleaned.padStart(2, '0');
|
||||
} else if (cleaned.length === 2) {
|
||||
hours = cleaned;
|
||||
} else if (cleaned.length === 3) {
|
||||
hours = cleaned.slice(0, 1).padStart(2, '0');
|
||||
minutes = cleaned.slice(1);
|
||||
} else {
|
||||
hours = cleaned.slice(0, 2);
|
||||
minutes = cleaned.slice(2, 4);
|
||||
}
|
||||
|
||||
let h = parseInt(hours);
|
||||
let m = parseInt(minutes);
|
||||
|
||||
if (isNaN(h)) h = 0;
|
||||
if (isNaN(m)) m = 0;
|
||||
|
||||
if (h > 23) h = 23;
|
||||
if (m > 59) m = 59;
|
||||
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const formatted = formatTime(tempValue);
|
||||
if (formatted) {
|
||||
setTempValue(formatted);
|
||||
onChange(formatted);
|
||||
} else {
|
||||
setTempValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Dial Picker Logic
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'));
|
||||
|
||||
const [selectedHour, setSelectedHour] = useState(value.split(':')[0] || '08');
|
||||
const [selectedMinute, setSelectedMinute] = useState(value.split(':')[1] || '00');
|
||||
|
||||
const openPicker = () => {
|
||||
setSelectedHour(value.split(':')[0] || '08');
|
||||
setSelectedMinute(value.split(':')[1] || '00');
|
||||
setShowPicker(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const confirmPicker = () => {
|
||||
const newTime = `${selectedHour}:${selectedMinute}`;
|
||||
onChange(newTime);
|
||||
setTempValue(newTime);
|
||||
setShowPicker(false);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={[styles.label, { color: colors.outline }]}>{label}</Text>}
|
||||
<View style={[styles.inputWrapper, { backgroundColor: colors.surface, borderColor: colors.outlineVariant + '4D' }]}>
|
||||
<TouchableOpacity onPress={openPicker}>
|
||||
<Clock size={20} color={colors.primary} style={styles.inputIcon} />
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.onSurface }]}
|
||||
value={tempValue}
|
||||
onChangeText={setTempValue}
|
||||
onBlur={handleBlur}
|
||||
placeholder="00:00"
|
||||
placeholderTextColor={colors.outline}
|
||||
keyboardType="number-pad"
|
||||
maxLength={5}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Modal visible={showPicker} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: colors.onSurface }]}>Select Time</Text>
|
||||
<TouchableOpacity onPress={() => setShowPicker(false)}>
|
||||
<X size={24} color={colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.pickerContainer}>
|
||||
<View style={styles.dialWrapper}>
|
||||
<Text style={[styles.dialLabel, { color: colors.outline }]}>HOUR</Text>
|
||||
<FlatList
|
||||
data={HOURS}
|
||||
keyExtractor={item => item}
|
||||
showsVerticalScrollIndicator={false}
|
||||
snapToInterval={40}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.dialItem, selectedHour === item && { backgroundColor: colors.primaryContainer }]}
|
||||
onPress={() => {
|
||||
setSelectedHour(item);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.dialText, { color: selectedHour === item ? colors.primary : colors.onSurface }]}>
|
||||
{item}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
style={styles.dialList}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.separator, { color: colors.onSurface }]}>:</Text>
|
||||
|
||||
<View style={styles.dialWrapper}>
|
||||
<Text style={[styles.dialLabel, { color: colors.outline }]}>MIN</Text>
|
||||
<FlatList
|
||||
data={MINUTES}
|
||||
keyExtractor={item => item}
|
||||
showsVerticalScrollIndicator={false}
|
||||
snapToInterval={40}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.dialItem, selectedMinute === item && { backgroundColor: colors.primaryContainer }]}
|
||||
onPress={() => {
|
||||
setSelectedMinute(item);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.dialText, { color: selectedMinute === item ? colors.primary : colors.onSurface }]}>
|
||||
{item}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
style={styles.dialList}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.confirmBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={confirmPicker}
|
||||
>
|
||||
<Check size={20} color={colors.onPrimary} />
|
||||
<Text style={[styles.confirmBtnText, { color: colors.onPrimary }]}>Confirm Time</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 11,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: ROUNDNESS.md,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
fontFamily: FONTS.body,
|
||||
fontSize: 16,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: ROUNDNESS.xl,
|
||||
borderTopRightRadius: ROUNDNESS.xl,
|
||||
padding: SPACING.lg,
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
modalTitle: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 18,
|
||||
},
|
||||
pickerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 20,
|
||||
height: 200,
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
dialWrapper: {
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
},
|
||||
dialLabel: {
|
||||
fontFamily: FONTS.label,
|
||||
fontSize: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
dialList: {
|
||||
height: 160,
|
||||
width: '100%',
|
||||
},
|
||||
dialItem: {
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: ROUNDNESS.sm,
|
||||
},
|
||||
dialText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 20,
|
||||
},
|
||||
separator: {
|
||||
fontFamily: FONTS.headline,
|
||||
fontSize: 24,
|
||||
marginTop: 20,
|
||||
},
|
||||
confirmBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
height: 56,
|
||||
borderRadius: ROUNDNESS.full,
|
||||
},
|
||||
confirmBtnText: {
|
||||
fontFamily: FONTS.labelSm,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
export const ACCENTS = {
|
||||
slate: {
|
||||
label: "Modern Slate",
|
||||
primary: "#334155",
|
||||
primaryContainer: "#f1f5f9",
|
||||
onPrimaryContainer: "#0f172a",
|
||||
},
|
||||
blue: {
|
||||
label: "Ocean Blue",
|
||||
primary: "#4e607b",
|
||||
primaryContainer: "#d3e3ff",
|
||||
onPrimaryContainer: "#40536d",
|
||||
},
|
||||
pink: {
|
||||
label: "Rose Pink",
|
||||
primary: "#a8385a",
|
||||
primaryContainer: "#ffd9df",
|
||||
onPrimaryContainer: "#3e001a",
|
||||
},
|
||||
red: {
|
||||
label: "Crimson Rust",
|
||||
primary: "#a84c36",
|
||||
primaryContainer: "#ffdad2",
|
||||
onPrimaryContainer: "#3e0a01",
|
||||
},
|
||||
purple: {
|
||||
label: "Royal Amethyst",
|
||||
primary: "#7c4dff",
|
||||
primaryContainer: "#e0e0ff",
|
||||
onPrimaryContainer: "#2c0091",
|
||||
},
|
||||
orange: {
|
||||
label: "Deep Amber",
|
||||
primary: "#f57c00",
|
||||
primaryContainer: "#fff3e0",
|
||||
onPrimaryContainer: "#e65100",
|
||||
},
|
||||
teal: {
|
||||
label: "Deep Teal",
|
||||
primary: "#00796b",
|
||||
primaryContainer: "#e0f2f1",
|
||||
onPrimaryContainer: "#004d40",
|
||||
},
|
||||
emerald: {
|
||||
label: "Vibrant Emerald",
|
||||
primary: "#2e7d32",
|
||||
primaryContainer: "#e8f5e9",
|
||||
onPrimaryContainer: "#1b5e20",
|
||||
},
|
||||
};
|
||||
|
||||
export type AccentKey = keyof typeof ACCENTS;
|
||||
|
||||
export const COLORS = {
|
||||
light: {
|
||||
primary: "#334155", // Slate
|
||||
onPrimary: "#f8faf9",
|
||||
primaryContainer: "#f1f5f9",
|
||||
onPrimaryContainer: "#0f172a",
|
||||
secondary: "#4e607b", // Soft Blue
|
||||
onSecondary: "#f8f8ff",
|
||||
secondaryContainer: "#d3e3ff",
|
||||
onSecondaryContainer: "#40536d",
|
||||
tertiary: "#655b6f", // Muted Lavender
|
||||
onTertiary: "#fef6ff",
|
||||
background: "#f8faf9", // Off-white
|
||||
onBackground: "#2d3433",
|
||||
surface: "#f8faf9",
|
||||
onSurface: "#2d3433",
|
||||
surfaceVariant: "#eaefee",
|
||||
onSurfaceVariant: "#596060",
|
||||
outline: "#757c7b",
|
||||
outlineVariant: "#acb3b2",
|
||||
error: "#a83836",
|
||||
onError: "#fff7f6",
|
||||
},
|
||||
dark: {
|
||||
primary: "#94a3b8", // Light Slate
|
||||
onPrimary: "#0f172a",
|
||||
primaryContainer: "#1e293b",
|
||||
onPrimaryContainer: "#f1f5f9",
|
||||
secondary: "#c2d6f5", // Tonal lighter Soft Blue
|
||||
onSecondary: "#2e405a",
|
||||
secondaryContainer: "#4a5d77",
|
||||
onSecondaryContainer: "#d3e3ff",
|
||||
tertiary: "#e5d8f0", // Tonal lighter Lavender
|
||||
onTertiary: "#4a4154",
|
||||
background: "#000000", // Pure black
|
||||
onBackground: "#f1f4f3",
|
||||
surface: "#000000",
|
||||
onSurface: "#f1f4f3",
|
||||
surfaceVariant: "#1a1a1a",
|
||||
onSurfaceVariant: "#acb3b2",
|
||||
outline: "#acb3b2",
|
||||
outlineVariant: "#596060",
|
||||
error: "#fa746f",
|
||||
onError: "#6e0a12",
|
||||
},
|
||||
};
|
||||
|
||||
export type ThemeColors = typeof COLORS.light;
|
||||
|
||||
export const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
giant: 64,
|
||||
};
|
||||
|
||||
export const ROUNDNESS = {
|
||||
none: 0,
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 24,
|
||||
full: 9999,
|
||||
};
|
||||
|
||||
export const FONTS = {
|
||||
headline: "Manrope_700Bold",
|
||||
body: "Manrope_400Regular",
|
||||
label: "PlusJakartaSans_500Medium",
|
||||
labelSm: "PlusJakartaSans_700Bold",
|
||||
};
|
||||
|
||||
export const GRID_STYLE = {
|
||||
ghostBorder: (scheme: "light" | "dark") =>
|
||||
scheme === "light" ? "rgba(51, 65, 85, 0.1)" : "rgba(148, 163, 184, 0.1)",
|
||||
};
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
export const DATABASE_NAME = 'batsir.db';
|
||||
|
||||
let dbInstance: SQLite.SQLiteDatabase | null = null;
|
||||
let initPromise: Promise<SQLite.SQLiteDatabase> | null = null;
|
||||
|
||||
export const initDatabase = async () => {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
if (!dbInstance) {
|
||||
dbInstance = await SQLite.openDatabaseAsync(DATABASE_NAME);
|
||||
}
|
||||
const db = dbInstance;
|
||||
|
||||
await db.execAsync('PRAGMA journal_mode = WAL;');
|
||||
|
||||
const tables = [
|
||||
`CREATE TABLE IF NOT EXISTS habits (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
preferred_time TEXT,
|
||||
weekend_flexibility INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
current_streak INTEGER DEFAULT 0,
|
||||
max_streak INTEGER DEFAULT 0,
|
||||
two_minute_version TEXT,
|
||||
location TEXT,
|
||||
anchor_habit_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
date TEXT NOT NULL,
|
||||
time_blocks TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS logs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
habit_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
logged_at TEXT DEFAULT (datetime('now')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (habit_id) REFERENCES habits (id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS shortcuts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
estimated_sessions INTEGER DEFAULT 1,
|
||||
completed_sessions INTEGER DEFAULT 0,
|
||||
tag TEXT,
|
||||
todos TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS sync_history (
|
||||
old_id TEXT PRIMARY KEY NOT NULL,
|
||||
new_id TEXT NOT NULL,
|
||||
table_name TEXT NOT NULL,
|
||||
synced_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS books (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
total_pages INTEGER DEFAULT 0,
|
||||
current_page INTEGER DEFAULT 0,
|
||||
last_page_read INTEGER DEFAULT 0,
|
||||
file_uri TEXT,
|
||||
cover_uri TEXT,
|
||||
status TEXT DEFAULT 'want_to_read',
|
||||
synthesis TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS reading_logs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
book_id TEXT NOT NULL,
|
||||
start_page INTEGER DEFAULT 0,
|
||||
end_page INTEGER DEFAULT 0,
|
||||
pages_read INTEGER DEFAULT 0,
|
||||
duration_minutes REAL DEFAULT 0,
|
||||
duration_seconds REAL DEFAULT 0,
|
||||
logged_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS reading_sessions (
|
||||
book_id TEXT PRIMARY KEY NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
start_page INTEGER NOT NULL,
|
||||
last_update_time INTEGER NOT NULL,
|
||||
accumulated_time INTEGER NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT,
|
||||
book_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE
|
||||
);`
|
||||
];
|
||||
|
||||
for (const tableSql of tables) {
|
||||
try {
|
||||
await db.execAsync(tableSql);
|
||||
} catch (err) {
|
||||
console.error('Error creating table:', err, tableSql);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const habitsInfo = await db.getAllAsync(`PRAGMA table_info(habits)`);
|
||||
const hCols = (habitsInfo as any[]).map(c => c.name);
|
||||
if (!hCols.includes('preferred_time')) await db.execAsync(`ALTER TABLE habits ADD COLUMN preferred_time TEXT;`);
|
||||
if (!hCols.includes('weekend_flexibility')) await db.execAsync(`ALTER TABLE habits ADD COLUMN weekend_flexibility INTEGER DEFAULT 0;`);
|
||||
if (!hCols.includes('current_streak')) await db.execAsync(`ALTER TABLE habits ADD COLUMN current_streak INTEGER DEFAULT 0;`);
|
||||
if (!hCols.includes('max_streak')) await db.execAsync(`ALTER TABLE habits ADD COLUMN max_streak INTEGER DEFAULT 0;`);
|
||||
if (!hCols.includes('two_minute_version')) await db.execAsync(`ALTER TABLE habits ADD COLUMN two_minute_version TEXT;`);
|
||||
if (!hCols.includes('location')) await db.execAsync(`ALTER TABLE habits ADD COLUMN location TEXT;`);
|
||||
if (!hCols.includes('anchor_habit_id')) await db.execAsync(`ALTER TABLE habits ADD COLUMN anchor_habit_id TEXT;`);
|
||||
|
||||
const taskInfo = await db.getAllAsync(`PRAGMA table_info(tasks)`);
|
||||
const tCols = (taskInfo as any[]).map(c => c.name);
|
||||
if (!tCols.includes('todos')) await db.execAsync(`ALTER TABLE tasks ADD COLUMN todos TEXT NOT NULL DEFAULT '[]';`);
|
||||
|
||||
const readingLogInfo = await db.getAllAsync(`PRAGMA table_info(reading_logs)`);
|
||||
const rlCols = (readingLogInfo as any[]).map(c => c.name);
|
||||
if (!rlCols.includes('user_id')) await db.execAsync(`ALTER TABLE reading_logs ADD COLUMN user_id TEXT;`);
|
||||
if (!rlCols.includes('duration_seconds')) await db.execAsync(`ALTER TABLE reading_logs ADD COLUMN duration_seconds REAL DEFAULT 0;`);
|
||||
if (!rlCols.includes('start_page')) await db.execAsync(`ALTER TABLE reading_logs ADD COLUMN start_page INTEGER DEFAULT 0;`);
|
||||
if (!rlCols.includes('end_page')) await db.execAsync(`ALTER TABLE reading_logs ADD COLUMN end_page INTEGER DEFAULT 0;`);
|
||||
|
||||
const bookmarksInfo = await db.getAllAsync(`PRAGMA table_info(bookmarks)`);
|
||||
const bCols = (bookmarksInfo as any[]).map(c => c.name);
|
||||
if (bCols.length > 0 && !bCols.includes('user_id')) {
|
||||
await db.execAsync(`ALTER TABLE bookmarks ADD COLUMN user_id TEXT;`);
|
||||
}
|
||||
|
||||
const logsInfo = await db.getAllAsync(`PRAGMA table_info(logs)`);
|
||||
const lCols = (logsInfo as any[]).map(c => c.name);
|
||||
if (!lCols.includes('user_id')) await db.execAsync(`ALTER TABLE logs ADD COLUMN user_id TEXT;`);
|
||||
|
||||
const booksInfo = await db.getAllAsync(`PRAGMA table_info(books)`);
|
||||
const bookCols = (booksInfo as any[]).map(c => c.name);
|
||||
if (!bookCols.includes('last_page_read')) await db.execAsync(`ALTER TABLE books ADD COLUMN last_page_read INTEGER DEFAULT 0;`);
|
||||
if (!bookCols.includes('synthesis')) await db.execAsync(`ALTER TABLE books ADD COLUMN synthesis TEXT;`);
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
}
|
||||
|
||||
return db;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
};
|
||||
|
||||
export const getDb = async () => {
|
||||
return initDatabase();
|
||||
};
|
||||