diff --git a/.gitignore b/.gitignore index 9c4832a..9a85917 100644 --- a/.gitignore +++ b/.gitignore @@ -242,7 +242,7 @@ temp/ # Add your project-specific ignores here uploads/ public/uploads/ -storage/ +/storage/ # Drizzle drizzle/ diff --git a/.releaserc.json b/.releaserc.json index 906c795..1366ced 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,13 +1,21 @@ { "branches": ["main"], "plugins": [ - "@semantic-release/commit-analyzer", + [ + "@semantic-release/commit-analyzer", + { + "releaseRules": [ + { "scope": "mobile", "release": false } + ] + } + ], "@semantic-release/release-notes-generator", "@semantic-release/changelog", [ "@semantic-release/exec", { - "prepareCmd": "node scripts/update-version.js ${nextRelease.version}" + "prepareCmd": "node scripts/update-version.js ${nextRelease.version}", + "publishCmd": "node scripts/update-mobile-version.js" } ], [ @@ -17,7 +25,9 @@ "CHANGELOG.md", "package.json", "src/constants/version.ts", - "apps/desktop/package.json" + "apps/desktop/package.json", + "apps/mobile/v1/package.json", + "apps/mobile/v1/app.json" ], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } diff --git a/apps/mobile/v1/.env.example b/apps/mobile/v1/.env.example new file mode 100644 index 0000000..c0b91ff --- /dev/null +++ b/apps/mobile/v1/.env.example @@ -0,0 +1,10 @@ +# Clerk Authentication +# Get your publishable key from https://dashboard.clerk.com +EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here + +# API Configuration +# Set to your API endpoint +EXPO_PUBLIC_API_URL=https://api.typelets.com/api + +# Development/Testing +# EXPO_PUBLIC_API_URL=http://localhost:3000/api \ No newline at end of file diff --git a/apps/mobile/v1/.gitignore b/apps/mobile/v1/.gitignore new file mode 100644 index 0000000..0c2c5f2 --- /dev/null +++ b/apps/mobile/v1/.gitignore @@ -0,0 +1,44 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# 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 diff --git a/apps/mobile/v1/README.md b/apps/mobile/v1/README.md new file mode 100644 index 0000000..ffd1ba0 --- /dev/null +++ b/apps/mobile/v1/README.md @@ -0,0 +1,236 @@ +# Typelets Mobile App + +React Native mobile application for Typelets - a secure, encrypted note-taking platform. + +## Features + +- š **End-to-end encryption** with master password protection +- š± **Cross-platform** - iOS and Android support via React Native +- š **Folder organization** with nested folder support +- ā **Starred notes** for quick access +- šļø **Trash management** with restore capabilities +- šØ **Theme support** - Light and dark modes +- š **Real-time sync** with backend API +- š **Rich text editing** powered by TipTap + +## Tech Stack + +- **Framework**: React Native with Expo 54 +- **Routing**: Expo Router (file-based) +- **Authentication**: Clerk +- **Encryption**: AES-GCM with PBKDF2 key derivation +- **State Management**: React hooks +- **Storage**: Expo SecureStore +- **Editor**: TipTap (WebView-based) + +## Architecture + +### Modular Structure + +The app follows a clean, modular architecture: + +``` +src/ +āāā components/ # Reusable UI components +ā āāā MasterPasswordDialog/ # Password UI (modular) +ā āāā ui/ # Base UI components +āāā screens/ # Main app screens +āāā services/ # API and business logic +ā āāā api/ # Modular API service +āāā lib/ # Core libraries +ā āāā encryption/ # Modular encryption service +āāā hooks/ # Custom React hooks +āāā theme/ # Theme configuration +``` + +### Key Modules + +**API Service** (`src/services/api/`) +- Modular REST API client with authentication +- Separated concerns: notes, folders, encryption +- Centralized error handling and pagination + +**Encryption Service** (`src/lib/encryption/`) +- AES-GCM encryption with 250,000 PBKDF2 iterations +- Master password support with secure key storage +- LRU cache for decrypted notes (15-minute TTL) + +**Master Password UI** (`src/components/MasterPasswordDialog/`) +- Modular password setup/unlock flows +- Custom hooks for validation and keyboard handling +- Optimized for long-running PBKDF2 operations + +## Getting Started + +### Prerequisites + +- Node.js 18+ and npm +- Expo CLI: `npm install -g expo-cli` +- For iOS: Xcode and iOS Simulator +- For Android: Android Studio and Android Emulator + +### Installation + +1. **Install dependencies** + ```bash + npm install + ``` + +2. **Set up environment variables** + ```bash + cp .env.example .env + ``` + + Edit `.env` and add: + ``` + EXPO_PUBLIC_API_URL=your_api_url + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key + ``` + +3. **Start development server** + ```bash + npx expo start + ``` + +4. **Run on device/emulator** + - Press `a` for Android + - Press `i` for iOS + - Scan QR code with Expo Go app + +### Building for Production + +#### Android (Preview Build) +```bash +eas build --platform android --profile preview +``` + +#### Android (Production Build) +```bash +eas build --platform android --profile production +``` + +#### iOS (Production Build) +```bash +eas build --platform ios --profile production +``` + +## Development + +### Linting +```bash +npm run lint +``` + +### Type Checking +```bash +npx tsc --noEmit +``` + +### Clear Cache +```bash +npx expo start --clear +``` + +## Project Structure + +### Screens +- `app/index.tsx` - Folders list (home) +- `app/folder-notes.tsx` - Notes in a folder +- `app/view-note.tsx` - View note (read-only) +- `app/edit-note.tsx` - Edit note with TipTap +- `app/settings.tsx` - App settings + +### API Integration + +The app uses a modular API service with automatic authentication: + +```typescript +const { getNotes, createNote, getFolders } = useApiService(); + +// Fetch notes with encryption/decryption +const notes = await getNotes({ folderId: 'abc123' }); + +// Create encrypted note +await createNote({ + title: 'My Note', + content: '
Content
', + folderId: 'abc123' +}); +``` + +### Encryption + +Master password encryption is handled automatically: + +```typescript +import { + setupMasterPassword, + unlockWithMasterPassword, + hasMasterPassword +} from '@/lib/encryption'; + +// Setup (first time) +await setupMasterPassword('my-password', userId); + +// Unlock (returning user) +const success = await unlockWithMasterPassword('my-password', userId); +``` + +## Configuration + +### EAS Build Configuration + +See `eas.json` for build profiles: +- **development**: Dev client builds +- **preview**: Internal testing builds +- **production**: App Store/Play Store builds + +### App Configuration + +See `app.json` for: +- App name, bundle identifiers +- Icon and splash screen +- iOS/Android specific settings +- App version (auto-updated by semantic-release) + +## Version Management + +App versions are automatically managed by semantic-release based on conventional commits: + +- `feat(mobile):` ā Minor version bump +- `fix(mobile):` ā Patch version bump +- `refactor(mobile):` ā Patch version bump +- Breaking changes ā Major version bump + +The version is synced across: +- `package.json` +- `app.json` (expo.version) +- iOS buildNumber (auto-incremented) +- Android versionCode (auto-incremented) + +## Security + +- **Encryption**: AES-GCM with PBKDF2 (250k iterations) +- **Key Storage**: Expo SecureStore (iOS Keychain / Android Keystore) +- **Authentication**: Clerk with JWT tokens +- **Transport**: HTTPS only +- **Master Password**: Never stored, derived on-device + +## Contributing + +1. Create a feature branch from `main` +2. Use conventional commits: `feat(mobile):`, `fix(mobile):`, etc. +3. Run linting before committing: `npm run lint` +4. Test on both iOS and Android if possible +5. Create a PR with clear description + +## Resources + +- [Expo Documentation](https://docs.expo.dev/) +- [React Native Documentation](https://reactnative.dev/) +- [Clerk Documentation](https://clerk.com/docs) +- [EAS Build](https://docs.expo.dev/build/introduction/) + +## License + +See main repository LICENSE file. diff --git a/apps/mobile/v1/app.json b/apps/mobile/v1/app.json new file mode 100644 index 0000000..1878516 --- /dev/null +++ b/apps/mobile/v1/app.json @@ -0,0 +1,89 @@ +{ + "expo": { + "name": "Typelets", + "slug": "typelets", + "version": "1.0.0", + "orientation": "default", + "icon": "./assets/images/icon.png", + "scheme": "typelets", + "userInterfaceStyle": "automatic", + "description": "Secure, encrypted note-taking app with seamless sync across all your devices. Organize your thoughts with folders, encrypt sensitive content, and access your notes anywhere.", + "keywords": [ + "notes", + "notepad", + "encrypted", + "secure", + "sync", + "productivity", + "writing", + "journal", + "documents", + "typelets" + ], + "privacy": "public", + "privacyPolicyUrl": "https://typelets.com/privacy", + "platforms": [ + "ios", + "android" + ], + "ios": { + "bundleIdentifier": "com.typelets.notes", + "buildNumber": "1", + "supportsTablet": true, + "infoPlist": { + "NSCameraUsageDescription": "This app uses the camera to capture photos for your notes.", + "NSPhotoLibraryUsageDescription": "This app accesses your photo library to attach images to your notes.", + "NSMicrophoneUsageDescription": "This app uses the microphone to record audio notes." + } + }, + "android": { + "package": "com.typelets.notes", + "versionCode": 1, + "softwareKeyboardLayoutMode": "resize", + "adaptiveIcon": { + "backgroundColor": "#FFFFFF", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "permissions": [ + "INTERNET", + "ACCESS_NETWORK_STATE" + ] + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#25262b" + } + } + ] + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "cf36dcdb-7234-4bf4-9b3b-8c64d31bf8e3" + } + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "owner": "typelets", + "category": "productivity" + } +} diff --git a/apps/mobile/v1/app/_layout.tsx b/apps/mobile/v1/app/_layout.tsx new file mode 100644 index 0000000..8aa1ee2 --- /dev/null +++ b/apps/mobile/v1/app/_layout.tsx @@ -0,0 +1,125 @@ +import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } from '@react-navigation/native'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { View } from 'react-native'; +import 'react-native-reanimated'; +import { ClerkProvider, ClerkLoaded } from '@clerk/clerk-expo'; +import { tokenCache } from '@clerk/clerk-expo/token-cache'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; + +import { ThemeProvider, useTheme } from '@/src/theme'; +import { AppWrapper } from '@/src/components/AppWrapper'; +import ErrorBoundary from '@/src/components/ErrorBoundary'; + +const clerkPublishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY || ''; + +export const unstable_settings = { + anchor: '(tabs)', +}; + +function NavigationContent() { + const theme = useTheme(); + + // Create custom navigation theme with app colors + const customLightTheme = { + ...DefaultTheme, + colors: { + ...DefaultTheme.colors, + background: theme.colors.background, + card: theme.colors.card, + border: theme.colors.border, + primary: theme.colors.primary, + }, + }; + + const customDarkTheme = { + ...DarkTheme, + colors: { + ...DarkTheme.colors, + background: theme.colors.background, + card: theme.colors.card, + border: theme.colors.border, + primary: theme.colors.primary, + }, + }; + + return ( +]*)>/gi, + `` + ); + + // Add inline styles to code tags (inline code) + processed = processed.replace( + /]*)>/gi, + `` + ); + + // Add inline styles to blockquote tags + processed = processed.replace( + /]*)>/gi, + `` + ); + + return processed; +}; \ No newline at end of file diff --git a/apps/mobile/v1/src/lib/encryption/EncryptionService.ts b/apps/mobile/v1/src/lib/encryption/EncryptionService.ts new file mode 100644 index 0000000..fe30e94 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/EncryptionService.ts @@ -0,0 +1,193 @@ +/** + * Main Encryption Service + * Orchestrates encryption/decryption operations + */ + +import * as Crypto from 'expo-crypto'; +import { EncryptedNote, DecryptedData, PotentiallyEncrypted } from './types'; +import { ENCRYPTION_CONFIG } from './config'; +import { encryptWithAESGCM, decryptWithAESGCM } from './core/aes'; +import { deriveEncryptionKey } from './core/keyDerivation'; +import { arrayBufferToBase64 } from './core/crypto'; +import { getUserSecret, getMasterKey, clearUserStorageData } from './storage/secureStorage'; +import { DecryptionCache } from './storage/cache'; + +export class MobileEncryptionService { + private cache: DecryptionCache; + private masterPasswordMode = false; + + constructor() { + this.cache = new DecryptionCache(); + } + + /** + * Derive encryption key for a user + */ + private async deriveKey(userId: string, saltBase64: string): Promise{ + if (!userId) { + throw new Error('Key derivation attempted without user ID'); + } + + try { + const masterKey = await getMasterKey(userId); + + if (this.masterPasswordMode && masterKey) { + // In master password mode, return the stored key directly + return masterKey; + } + + // For non-master password mode, derive key from user secret and salt + const userSecret = await getUserSecret(userId); + return await deriveEncryptionKey(userId, userSecret, saltBase64); + } catch (error) { + throw new Error(`Key derivation failed: ${error}`); + } + } + + /** + * Encrypt a note + */ + async encryptNote( + userId: string, + title: string, + content: string + ): Promise { + if (!userId) { + throw new Error('User ID required for encryption'); + } + + try { + // Generate random salt and IV + const saltBytes = await Crypto.getRandomBytesAsync(ENCRYPTION_CONFIG.SALT_LENGTH); + const ivBytes = await Crypto.getRandomBytesAsync(ENCRYPTION_CONFIG.IV_LENGTH); + + // Convert to base64 + const saltBase64 = arrayBufferToBase64(saltBytes); + const ivBase64 = arrayBufferToBase64(ivBytes); + + // Derive encryption key + const key = await this.deriveKey(userId, saltBase64); + + // Encrypt title and content + const encryptedTitle = await encryptWithAESGCM(title || '', key, ivBase64); + const encryptedContent = await encryptWithAESGCM(content || '', key, ivBase64); + + return { + encryptedTitle, + encryptedContent, + iv: ivBase64, + salt: saltBase64, + }; + } catch (error) { + throw new Error(`Encryption failed for user ${userId}: ${error}`); + } + } + + /** + * Decrypt a note + */ + async decryptNote( + userId: string, + encryptedTitle: string, + encryptedContent: string, + ivBase64: string, + saltBase64: string + ): Promise { + // Check cache first + const cached = this.cache.get(userId, encryptedTitle, ivBase64); + if (cached) { + return cached.data; + } + + try { + const key = await this.deriveKey(userId, saltBase64); + + // Decrypt title and content + const title = await decryptWithAESGCM(encryptedTitle, key, ivBase64); + const content = await decryptWithAESGCM(encryptedContent, key, ivBase64); + + // Validate decryption results + if (title === null || title === undefined) { + throw new Error('Title decryption failed'); + } + if (content === null || content === undefined) { + throw new Error('Content decryption failed'); + } + + const result = { title, content }; + + // Cache the result + this.cache.set(userId, encryptedTitle, ivBase64, result); + + return result; + } catch (error) { + throw new Error(`Decryption failed for user ${userId}: ${error}`); + } + } + + /** + * Check if a note is encrypted + */ + isEncrypted(note: PotentiallyEncrypted): boolean { + return !!( + note.encryptedTitle && + note.encryptedContent && + note.iv && + note.salt && + typeof note.encryptedTitle === 'string' && + typeof note.encryptedContent === 'string' && + typeof note.iv === 'string' && + typeof note.salt === 'string' + ); + } + + /** + * Clear all caches and keys + */ + clearKeys(): void { + this.cache.clearAll(); + } + + /** + * Clear cache for a specific note + */ + clearNoteCache(userId: string, encryptedTitle?: string): void { + this.cache.clearUser(userId, encryptedTitle); + } + + /** + * Clear all data for a user + */ + async clearUserData(userId: string): Promise { + if (__DEV__) { + console.log('šļø Starting clearUserData for user:', userId); + } + + // Clear storage + await clearUserStorageData(userId); + + // Clear cache + this.cache.clearUser(userId); + + // Reset master password mode + this.masterPasswordMode = false; + + if (__DEV__) { + console.log('ā clearUserData completed'); + } + } + + /** + * Enable master password mode + */ + enableMasterPasswordMode(): void { + this.masterPasswordMode = true; + } + + /** + * Destroy service + */ + destroy(): void { + this.cache.destroy(); + } +} diff --git a/apps/mobile/v1/src/lib/encryption/config.ts b/apps/mobile/v1/src/lib/encryption/config.ts new file mode 100644 index 0000000..4bec8d5 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/config.ts @@ -0,0 +1,18 @@ +/** + * Encryption Configuration Constants + */ + +export const ENCRYPTION_CONFIG = { + ALGORITHM: 'AES-GCM' as const, + KEY_LENGTH: 256, + IV_LENGTH: 16, + ITERATIONS: 250000, // Match web app for compatibility + SALT_LENGTH: 32, + GCM_TAG_LENGTH: 16, // GCM auth tag is 16 bytes +} as const; + +export const CACHE_CONFIG = { + MAX_SIZE: 100, + TTL_MS: 1000 * 60 * 15, // 15 minutes + CLEANUP_INTERVAL_MS: 1000 * 60 * 5, // 5 minutes +} as const; diff --git a/apps/mobile/v1/src/lib/encryption/core/aes.ts b/apps/mobile/v1/src/lib/encryption/core/aes.ts new file mode 100644 index 0000000..99dee41 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/core/aes.ts @@ -0,0 +1,100 @@ +/** + * AES-GCM Encryption/Decryption + * Using node-forge for compatibility with web app + */ + +import forge from 'node-forge'; +import { ENCRYPTION_CONFIG } from '../config'; + +/** + * Encrypt plaintext using AES-GCM + */ +export async function encryptWithAESGCM( + plaintext: string, + keyBase64: string, + ivBase64: string +): Promise { + try { + // Convert base64 to forge-compatible format + const key = forge.util.decode64(keyBase64); + const iv = forge.util.decode64(ivBase64); + + // Create AES-GCM cipher + const cipher = forge.cipher.createCipher('AES-GCM', key); + + // Start encryption with IV + cipher.start({ iv }); + + // Update with plaintext + cipher.update(forge.util.createBuffer(plaintext, 'utf8')); + + // Finish encryption + cipher.finish(); + + // Get ciphertext and auth tag + const ciphertext = cipher.output.getBytes(); + const authTag = cipher.mode.tag.getBytes(); + + // Combine ciphertext + auth tag (Web Crypto API format) + const encryptedWithTag = ciphertext + authTag; + + // Convert to base64 + return forge.util.encode64(encryptedWithTag); + } catch (error) { + throw new Error(`AES-GCM encryption failed: ${error}`); + } +} + +/** + * Decrypt ciphertext using AES-GCM + */ +export async function decryptWithAESGCM( + encryptedBase64: string, + keyBase64: string, + ivBase64: string +): Promise { + try { + // Convert base64 to forge-compatible format + const key = forge.util.decode64(keyBase64); + const iv = forge.util.decode64(ivBase64); + const encryptedDataWithTag = forge.util.decode64(encryptedBase64); + + // For node-forge GCM, we need to manually handle the auth tag + // Web Crypto API embeds the auth tag at the end of the encrypted data + const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH; + + if (encryptedDataWithTag.length < tagLength) { + throw new Error( + `Encrypted data too short for GCM (${encryptedDataWithTag.length} bytes, need at least ${tagLength})` + ); + } + + // Split the data: ciphertext + auth tag (last 16 bytes) + const ciphertext = encryptedDataWithTag.slice(0, -tagLength); + const authTag = encryptedDataWithTag.slice(-tagLength); + + // Create AES-GCM decipher + const decipher = forge.cipher.createDecipher('AES-GCM', key); + + // Start decryption with IV and auth tag + decipher.start({ + iv, + tag: authTag, + }); + + // Update with ciphertext + decipher.update(forge.util.createBuffer(ciphertext)); + + // Finish and verify auth tag + const success = decipher.finish(); + + if (!success) { + throw new Error('GCM authentication failed - auth tag verification failed'); + } + + const decryptedText = decipher.output.toString('utf8'); + return decryptedText; + } catch (error) { + throw new Error(`AES-GCM decryption failed: ${error}`); + } +} diff --git a/apps/mobile/v1/src/lib/encryption/core/crypto.ts b/apps/mobile/v1/src/lib/encryption/core/crypto.ts new file mode 100644 index 0000000..eedbf97 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/core/crypto.ts @@ -0,0 +1,32 @@ +/** + * Base Cryptographic Utilities + * Conversion and encoding helpers + */ + +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function uint8ArrayToString(bytes: Uint8Array): string { + const decoder = new TextDecoder('utf-8'); + return decoder.decode(bytes); +} + +export function stringToUint8Array(str: string): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(str); +} diff --git a/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts b/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts new file mode 100644 index 0000000..1a37416 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts @@ -0,0 +1,52 @@ +/** + * Key Derivation Functions + * PBKDF2 implementation using node-forge + */ + +import forge from 'node-forge'; +import { ENCRYPTION_CONFIG } from '../config'; +import { stringToUint8Array } from './crypto'; + +/** + * PBKDF2 implementation using node-forge to match web app + */ +export async function pbkdf2( + password: string, + salt: string, + iterations: number = ENCRYPTION_CONFIG.ITERATIONS, + keyLength: number = ENCRYPTION_CONFIG.KEY_LENGTH +): Promise { + try { + // Convert inputs to proper format to match web app + const passwordBytes = forge.util.encodeUtf8(password); + + // Convert salt string to bytes using TextEncoder equivalent + const saltUint8Array = stringToUint8Array(salt); + const saltBytes = forge.util.createBuffer(saltUint8Array).data; + + // Perform PBKDF2 computation (will block for ~2 minutes with 250k iterations) + const derivedKey = forge.pkcs5.pbkdf2( + passwordBytes, + saltBytes, + iterations, + keyLength / 8, // Convert bits to bytes + 'sha256' + ); + + return forge.util.encode64(derivedKey); + } catch (error) { + throw new Error(`PBKDF2 key derivation failed: ${error}`); + } +} + +/** + * Derive encryption key from user secret and salt + */ +export async function deriveEncryptionKey( + userId: string, + userSecret: string, + saltBase64: string +): Promise { + const keyMaterialString = `${userId}-${userSecret}-typelets-secure-v2`; + return pbkdf2(keyMaterialString, saltBase64); +} diff --git a/apps/mobile/v1/src/lib/encryption/index.ts b/apps/mobile/v1/src/lib/encryption/index.ts new file mode 100644 index 0000000..ac24c97 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/index.ts @@ -0,0 +1,116 @@ +/** + * Encryption Module - Main Exports + * Provides the same API as the old monolithic encryption.ts + */ + +import { MobileEncryptionService } from './EncryptionService'; +import { setupMasterPassword as setupMP } from './masterPassword/setup'; +import { unlockWithMasterPassword as unlockMP } from './masterPassword/unlock'; +import { + hasMasterPassword as hasMP, + isMasterPasswordUnlocked as isMPUnlocked, +} from './masterPassword/status'; +import type { PotentiallyEncrypted } from './types'; + +// Re-export types +export type { EncryptedNote, PotentiallyEncrypted, DecryptedData } from './types'; + +// Create singleton instance +export const encryptionService = new MobileEncryptionService(); + +/** + * Encrypt note data + */ +export async function encryptNoteData( + userId: string, + title: string, + content: string +) { + return encryptionService.encryptNote(userId, title, content); +} + +/** + * Decrypt note data + */ +export async function decryptNoteData( + userId: string, + encryptedTitle: string, + encryptedContent: string, + iv: string, + salt: string +) { + return encryptionService.decryptNote( + userId, + encryptedTitle, + encryptedContent, + iv, + salt + ); +} + +/** + * Check if note is encrypted + */ +export function isNoteEncrypted(note: unknown) { + return encryptionService.isEncrypted(note as PotentiallyEncrypted); +} + +/** + * Clear all encryption keys + */ +export function clearEncryptionKeys(): void { + encryptionService.clearKeys(); +} + +/** + * Clear user encryption data + */ +export async function clearUserEncryptionData(userId: string): Promise { + return encryptionService.clearUserData(userId); +} + +/** + * Clear note cache for user + */ +export function clearNoteCacheForUser(userId: string, encryptedTitle?: string): void { + return encryptionService.clearNoteCache(userId, encryptedTitle); +} + +/** + * Check if user has master password + */ +export async function hasMasterPassword(userId: string): Promise { + return hasMP(userId); +} + +/** + * Check if master password is unlocked + */ +export async function isMasterPasswordUnlocked(userId: string): Promise { + return isMPUnlocked(userId); +} + +/** + * Setup master password + */ +export async function setupMasterPassword( + masterPassword: string, + userId: string +): Promise { + await setupMP(masterPassword, userId); + encryptionService.enableMasterPasswordMode(); +} + +/** + * Unlock with master password + */ +export async function unlockWithMasterPassword( + masterPassword: string, + userId: string +): Promise { + const result = await unlockMP(masterPassword, userId); + if (result) { + encryptionService.enableMasterPasswordMode(); + } + return result; +} diff --git a/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts b/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts new file mode 100644 index 0000000..b492eda --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts @@ -0,0 +1,57 @@ +/** + * Master Password Setup + */ + +import { pbkdf2 } from '../core/keyDerivation'; +import { storeMasterKey, deleteOldUserSecret } from '../storage/secureStorage'; +import { getUserSalt } from '../storage/storageKeys'; + +/** + * Setup a new master password for a user + */ +export async function setupMasterPassword( + masterPassword: string, + userId: string +): Promise { + if (__DEV__) { + console.log('š setupMasterPassword called for user:', userId); + } + + if (!userId) { + throw new Error('Master password setup attempted without user ID'); + } + + try { + const userSalt = getUserSalt(userId); + + if (__DEV__) { + console.log('š Starting PBKDF2 key derivation...'); + } + + // Use PBKDF2 implementation (will block for ~2 minutes) + const keyString = await pbkdf2(masterPassword, userSalt); + + if (__DEV__) { + console.log('š PBKDF2 completed, storing keys...'); + } + + // Store master key + await storeMasterKey(userId, keyString); + + if (__DEV__) { + console.log('š Master key stored'); + } + + // Remove old key if exists + await deleteOldUserSecret(userId); + + if (__DEV__) { + console.log('š setupMasterPassword completed successfully'); + } + } catch (error) { + if (__DEV__) { + console.log('š setupMasterPassword error:', error); + } + throw new Error(`Master password setup failed: ${error}`); + } +} diff --git a/apps/mobile/v1/src/lib/encryption/masterPassword/status.ts b/apps/mobile/v1/src/lib/encryption/masterPassword/status.ts new file mode 100644 index 0000000..37a1934 --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/masterPassword/status.ts @@ -0,0 +1,34 @@ +/** + * Master Password Status Checks + */ + +import { hasMasterPasswordFlag, getMasterKey } from '../storage/secureStorage'; + +/** + * Check if user has a master password set + */ +export async function hasMasterPassword(userId: string): Promise { + try { + return await hasMasterPasswordFlag(userId); + } catch (error) { + if (__DEV__) { + console.error('hasMasterPassword error:', error); + } + return false; + } +} + +/** + * Check if master password is currently unlocked + */ +export async function isMasterPasswordUnlocked(userId: string): Promise { + try { + const masterKey = await getMasterKey(userId); + return masterKey !== null; + } catch (error) { + if (__DEV__) { + console.error('isMasterPasswordUnlocked error:', error); + } + return false; + } +} diff --git a/apps/mobile/v1/src/lib/encryption/masterPassword/unlock.ts b/apps/mobile/v1/src/lib/encryption/masterPassword/unlock.ts new file mode 100644 index 0000000..c306a0d --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/masterPassword/unlock.ts @@ -0,0 +1,48 @@ +/** + * Master Password Unlock + */ + +import * as SecureStore from 'expo-secure-store'; +import { pbkdf2 } from '../core/keyDerivation'; +import { decryptWithAESGCM } from '../core/aes'; +import { storeMasterKey, deleteOldUserSecret } from '../storage/secureStorage'; +import { getUserSalt, STORAGE_KEYS } from '../storage/storageKeys'; + +/** + * Unlock with master password + */ +export async function unlockWithMasterPassword( + masterPassword: string, + userId: string +): Promise { + try { + const userSalt = getUserSalt(userId); + + // Use PBKDF2 implementation + const keyString = await pbkdf2(masterPassword, userSalt); + + // Test if we can decrypt existing data (if any) + const testKey = STORAGE_KEYS.TEST_ENCRYPTION(userId); + try { + const testData = await SecureStore.getItemAsync(testKey); + if (testData) { + const testObj = JSON.parse(testData); + + // Try to decrypt test data with generated key + await decryptWithAESGCM(testObj.data, keyString, testObj.iv); + } + } catch { + return false; // Decryption failed, wrong password + } + + // Store master key + await storeMasterKey(userId, keyString); + + // Remove old key if exists + await deleteOldUserSecret(userId); + + return true; + } catch { + return false; + } +} diff --git a/apps/mobile/v1/src/lib/encryption/storage/cache.ts b/apps/mobile/v1/src/lib/encryption/storage/cache.ts new file mode 100644 index 0000000..f4f13ad --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/storage/cache.ts @@ -0,0 +1,134 @@ +/** + * Decryption Cache Management + * LRU-like cache for decrypted notes + */ + +import { CacheEntry } from '../types'; +import { CACHE_CONFIG } from '../config'; + +export class DecryptionCache { + private cache = new Map (); + private cleanupInterval: NodeJS.Timeout; + + constructor() { + // Clean expired cache every 5 minutes + this.cleanupInterval = setInterval( + () => this.cleanExpired(), + CACHE_CONFIG.CLEANUP_INTERVAL_MS + ); + } + + /** + * Generate cache key for a note + */ + private getCacheKey(userId: string, encryptedTitle: string, iv: string): string { + return `${userId}-${encryptedTitle}-${iv}`; + } + + /** + * Get cached decrypted data + */ + get(userId: string, encryptedTitle: string, iv: string): CacheEntry | undefined { + const key = this.getCacheKey(userId, encryptedTitle, iv); + const entry = this.cache.get(key); + + if (!entry) { + return undefined; + } + + // Check if expired + if (Date.now() - entry.timestamp > CACHE_CONFIG.TTL_MS) { + this.cache.delete(key); + return undefined; + } + + return entry; + } + + /** + * Set cached decrypted data + */ + set( + userId: string, + encryptedTitle: string, + iv: string, + data: { title: string; content: string } + ): void { + const key = this.getCacheKey(userId, encryptedTitle, iv); + + // Evict oldest entry if cache is full + if (this.cache.size >= CACHE_CONFIG.MAX_SIZE) { + const firstKey = this.cache.keys().next().value; + if (typeof firstKey === 'string') { + this.cache.delete(firstKey); + } + } + + this.cache.set(key, { + data, + timestamp: Date.now(), + }); + } + + /** + * Clear cache entries for a specific user + */ + clearUser(userId: string, encryptedTitle?: string): void { + const keysToDelete: string[] = []; + + this.cache.forEach((_, key) => { + if (encryptedTitle) { + // Clear specific note + if (key.startsWith(userId) && key.includes(encryptedTitle)) { + keysToDelete.push(key); + } + } else { + // Clear all for user + if (key.startsWith(userId)) { + keysToDelete.push(key); + } + } + }); + + keysToDelete.forEach((key) => this.cache.delete(key)); + + if (__DEV__ && keysToDelete.length > 0) { + console.log(`š§¹ Cleared ${keysToDelete.length} cache entries for user ${userId}`); + } + } + + /** + * Clear all cache entries + */ + clearAll(): void { + this.cache.clear(); + } + + /** + * Clean expired cache entries + */ + private cleanExpired(): void { + const now = Date.now(); + const expiredKeys: string[] = []; + + this.cache.forEach((entry, key) => { + if (now - entry.timestamp > CACHE_CONFIG.TTL_MS) { + expiredKeys.push(key); + } + }); + + expiredKeys.forEach((key) => this.cache.delete(key)); + + if (__DEV__ && expiredKeys.length > 0) { + console.log(`š§¹ Cleaned ${expiredKeys.length} expired cache entries`); + } + } + + /** + * Destroy cache and cleanup interval + */ + destroy(): void { + clearInterval(this.cleanupInterval); + this.cache.clear(); + } +} diff --git a/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts b/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts new file mode 100644 index 0000000..f0fa9de --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts @@ -0,0 +1,123 @@ +/** + * Secure Storage Wrapper + * Abstraction layer over expo-secure-store + */ + +import * as SecureStore from 'expo-secure-store'; +import * as Crypto from 'expo-crypto'; +import { STORAGE_KEYS } from './storageKeys'; +import { arrayBufferToBase64 } from '../core/crypto'; + +/** + * User secret management - in-memory cache + */ +const userSecretsCache = new Map (); + +/** + * Get or generate a user-specific secret + */ +export async function getUserSecret(userId: string): Promise { + // Check master key first + try { + const masterKey = await SecureStore.getItemAsync(STORAGE_KEYS.MASTER_KEY(userId)); + if (masterKey) { + return masterKey; + } + } catch { + // Continue to fallback + } + + // Check memory cache + if (userSecretsCache.has(userId)) { + return userSecretsCache.get(userId)!; + } + + // Use secure storage for user secrets + const storageKey = STORAGE_KEYS.USER_SECRET(userId); + try { + let secret = await SecureStore.getItemAsync(storageKey); + + if (!secret) { + // Generate new random secret and store it securely + const randomBytes = await Crypto.getRandomBytesAsync(64); + secret = arrayBufferToBase64(randomBytes); + await SecureStore.setItemAsync(storageKey, secret); + } + + // Cache in memory for performance + userSecretsCache.set(userId, secret); + return secret; + } catch (error) { + throw new Error(`Failed to get user secret: ${error}`); + } +} + +/** + * Store master key in secure storage + */ +export async function storeMasterKey(userId: string, keyString: string): Promise { + await SecureStore.setItemAsync(STORAGE_KEYS.MASTER_KEY(userId), keyString); + await SecureStore.setItemAsync(STORAGE_KEYS.HAS_MASTER_PASSWORD(userId), 'true'); +} + +/** + * Get master key from secure storage + */ +export async function getMasterKey(userId: string): Promise { + try { + return await SecureStore.getItemAsync(STORAGE_KEYS.MASTER_KEY(userId)); + } catch { + return null; + } +} + +/** + * Check if user has master password set + */ +export async function hasMasterPasswordFlag(userId: string): Promise { + try { + const flag = await SecureStore.getItemAsync(STORAGE_KEYS.HAS_MASTER_PASSWORD(userId)); + return flag === 'true'; + } catch { + return false; + } +} + +/** + * Clear all encryption data for a user + */ +export async function clearUserStorageData(userId: string): Promise { + const keysToDelete = [ + STORAGE_KEYS.MASTER_KEY(userId), + STORAGE_KEYS.HAS_MASTER_PASSWORD(userId), + STORAGE_KEYS.TEST_ENCRYPTION(userId), + STORAGE_KEYS.USER_SECRET(userId), + ]; + + for (const key of keysToDelete) { + try { + await SecureStore.deleteItemAsync(key); + if (__DEV__) { + console.log(`ā Deleted ${key}`); + } + } catch (error) { + if (__DEV__) { + console.log(`ā Failed to delete ${key}:`, error); + } + } + } + + // Clear from memory cache + userSecretsCache.delete(userId); +} + +/** + * Delete old user secret key + */ +export async function deleteOldUserSecret(userId: string): Promise { + try { + await SecureStore.deleteItemAsync(STORAGE_KEYS.USER_SECRET(userId)); + } catch { + // Ignore if doesn't exist + } +} diff --git a/apps/mobile/v1/src/lib/encryption/storage/storageKeys.ts b/apps/mobile/v1/src/lib/encryption/storage/storageKeys.ts new file mode 100644 index 0000000..9723a3d --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/storage/storageKeys.ts @@ -0,0 +1,18 @@ +/** + * Storage Key Generators + * Centralized key naming for SecureStore + */ + +export const STORAGE_KEYS = { + USER_SECRET: (userId: string) => `enc_secret_${userId}`, + MASTER_KEY: (userId: string) => `enc_master_key_${userId}`, + HAS_MASTER_PASSWORD: (userId: string) => `has_master_password_${userId}`, + TEST_ENCRYPTION: (userId: string) => `test_encryption_${userId}`, +} as const; + +export const SALT_PREFIX = 'typelets-salt'; +export const SALT_VERSION = 'v1'; + +export function getUserSalt(userId: string): string { + return `${SALT_PREFIX}-${userId}-${SALT_VERSION}`; +} diff --git a/apps/mobile/v1/src/lib/encryption/types.ts b/apps/mobile/v1/src/lib/encryption/types.ts new file mode 100644 index 0000000..3c6b9ca --- /dev/null +++ b/apps/mobile/v1/src/lib/encryption/types.ts @@ -0,0 +1,27 @@ +/** + * Encryption Type Definitions + */ + +export interface EncryptedNote { + encryptedTitle: string; + encryptedContent: string; + iv: string; + salt: string; +} + +export interface PotentiallyEncrypted { + encryptedTitle?: unknown; + encryptedContent?: unknown; + iv?: unknown; + salt?: unknown; +} + +export interface CacheEntry { + data: { title: string; content: string }; + timestamp: number; +} + +export interface DecryptedData { + title: string; + content: string; +} diff --git a/apps/mobile/v1/src/lib/tiptap-editor.html b/apps/mobile/v1/src/lib/tiptap-editor.html new file mode 100644 index 0000000..2f478f7 --- /dev/null +++ b/apps/mobile/v1/src/lib/tiptap-editor.html @@ -0,0 +1,179 @@ + + + + + + + + + + + ++ + + + + + + + diff --git a/apps/mobile/v1/src/screens/AuthScreen.tsx b/apps/mobile/v1/src/screens/AuthScreen.tsx new file mode 100644 index 0000000..7c41402 --- /dev/null +++ b/apps/mobile/v1/src/screens/AuthScreen.tsx @@ -0,0 +1,356 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Alert, + ScrollView, + Platform, + Keyboard, + Animated, + Dimensions, + TextInput, + TouchableOpacity +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSignIn, useSignUp } from '@clerk/clerk-expo'; +import { useTheme } from '../theme'; +import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; +import { Ionicons } from '@expo/vector-icons'; + +export default function AuthScreen() { + const theme = useTheme(); + const { signIn, setActive, isLoaded: signInLoaded } = useSignIn(); + const { signUp, setActive: setActiveSignUp, isLoaded: signUpLoaded } = useSignUp(); + + const [isSignUp, setIsSignUp] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [isForgotPassword, setIsForgotPassword] = useState(false); + const passwordRef = useRef(null); + + // Keyboard handling + const animatedValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const keyboardWillShowListener = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (event) => { + const { height: keyboardHeight } = event.endCoordinates; + + // Calculate how much to move up (less aggressive than full keyboard height) + const moveUpValue = keyboardHeight * 0.5; // Move up by half the keyboard height + + Animated.timing(animatedValue, { + toValue: -moveUpValue, + duration: Platform.OS === 'ios' ? 250 : 200, + useNativeDriver: true, + }).start(); + } + ); + + const keyboardWillHideListener = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + Animated.timing(animatedValue, { + toValue: 0, + duration: Platform.OS === 'ios' ? 250 : 200, + useNativeDriver: true, + }).start(); + } + ); + + return () => { + keyboardWillShowListener.remove(); + keyboardWillHideListener.remove(); + }; + }, [animatedValue]); + + const handleSignIn = async () => { + if (!signInLoaded) return; + setLoading(true); + + try { + const result = await signIn.create({ + identifier: email, + password, + }); + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }); + } + } catch { + Alert.alert('Sign In Error', 'Invalid email or password. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSignUp = async () => { + if (!signUpLoaded) return; + setLoading(true); + + try { + const result = await signUp.create({ + emailAddress: email, + password, + }); + + if (result.status === 'complete') { + await setActiveSignUp({ session: result.createdSessionId }); + } else { + // Handle email verification if needed + Alert.alert('Verification Required', 'Please check your email to verify your account.'); + } + } catch { + Alert.alert('Sign Up Error', 'Unable to create account. Please check your email and try again.'); + } finally { + setLoading(false); + } + }; + + const handleSubmit = () => { + if (!email.trim() || !password.trim()) { + Alert.alert('Error', 'Please fill in all fields'); + return; + } + + if (isSignUp) { + handleSignUp(); + } else { + handleSignIn(); + } + }; + + const handleForgotPassword = async () => { + if (!signInLoaded) return; + + if (!email.trim()) { + Alert.alert('Error', 'Please enter your email address'); + return; + } + + setLoading(true); + try { + await signIn.create({ + strategy: 'reset_password_email_code', + identifier: email, + }); + setIsForgotPassword(false); + } catch { + Alert.alert('Error', 'Failed to send reset email. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + animatedContainer: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + padding: 24, + justifyContent: 'center', + minHeight: Dimensions.get('window').height - 100, // Ensure full height minus some buffer + }, + header: { + alignItems: 'center', + marginBottom: 48, + paddingTop: 40, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + lineHeight: 22, + }, + formWrapper: { + width: '100%', + alignItems: 'center', + }, + form: { + width: '100%', + maxWidth: 480, + paddingBottom: 40, + }, + inputContainer: { + marginBottom: 24, + }, + label: { + fontSize: 16, + fontWeight: '500', + marginBottom: 8, + }, + submitButton: { + width: '100%', + marginTop: 16, + marginBottom: 16, + }, + switchButton: { + width: '100%', + }, + passwordContainer: { + position: 'relative', + }, + passwordInput: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + paddingRight: 48, + fontSize: 14, + minHeight: 40, + }, + eyeButton: { + position: 'absolute', + right: 12, + top: '50%', + transform: [{ translateY: -12 }], + padding: 4, + }, + forgotPasswordButton: { + alignSelf: 'flex-end', + marginTop: 8, + }, + forgotPasswordText: { + fontSize: 14, + fontWeight: '500', + }, +}); \ No newline at end of file diff --git a/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx b/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx new file mode 100644 index 0000000..1636c34 --- /dev/null +++ b/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +interface EditorHeaderProps { + isEditing: boolean; + noteData: unknown; + isSaving: boolean; + onBack: () => void; + onDelete: () => void; + onSave: () => void; + theme: { + colors: { + primary: string; + destructive: string; + destructiveForeground: string; + primaryForeground: string; + muted: string; + mutedForeground: string; + border: string; + }; + }; +} +export function EditorHeader({ + isEditing, + noteData, + isSaving, + onBack, + onDelete, + onSave, + theme, +}: EditorHeaderProps) { + return ( ++ ++ {/* Header */} + ++ + + {/* Form */} ++ {isForgotPassword ? 'Reset Password' : 'Welcome to Typelets'} + ++ {isForgotPassword ? 'Enter your email to receive a reset link' : isSignUp ? 'Create your account' : 'Sign in to continue'} + ++ ++ ++ + + {!isForgotPassword && ( ++ + )} + + {!isSignUp && !isForgotPassword && ( +Password ++ ++ setShowPassword(!showPassword)} + > + ++ setIsForgotPassword(true)} + style={styles.forgotPasswordButton} + > + + )} + + + + {isForgotPassword ? ( + + ) : ( + + )} ++ Forgot password? + ++ + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 8, + minHeight: 44, + }, + headerDivider: { + height: StyleSheet.hairlineWidth, + }, + headerButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 17, + }, + titleSpacer: { + flex: 1, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + actionButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/apps/mobile/v1/src/screens/EditNote/EditorToolbar.tsx b/apps/mobile/v1/src/screens/EditNote/EditorToolbar.tsx new file mode 100644 index 0000000..ab74431 --- /dev/null +++ b/apps/mobile/v1/src/screens/EditNote/EditorToolbar.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import type { EditorBridge } from '@10play/tentap-editor'; + +interface EditorToolbarProps { + editor: EditorBridge; + keyboardHeight: number; + theme: { + colors: { + background: string; + border: string; + foreground: string; + }; + }; +} +export function EditorToolbar({ editor, keyboardHeight, theme }: EditorToolbarProps) { + return ( ++ + ++ + + + {isEditing && ( + ++ + )} + ++ + {isSaving ? ( + ++ ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + toolbarContainer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 0.5, + paddingTop: 8, + paddingBottom: 24, + paddingHorizontal: 8, + }, + toolbarButtons: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + flex: 1, + }, + toolbarButton: { + paddingHorizontal: 6, + paddingVertical: 8, + borderRadius: 4, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + toolbarButtonText: { + fontSize: 15, + fontWeight: '600', + }, + toolbarDivider: { + width: 1, + height: 20, + marginHorizontal: 4, + }, +}); diff --git a/apps/mobile/v1/src/screens/EditNote/index.tsx b/apps/mobile/v1/src/screens/EditNote/index.tsx new file mode 100644 index 0000000..57c5f25 --- /dev/null +++ b/apps/mobile/v1/src/screens/EditNote/index.tsx @@ -0,0 +1,236 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, Alert, TextInput } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import * as Haptics from 'expo-haptics'; +import { RichText } from '@10play/tentap-editor'; +import { useTheme } from '../../theme'; +import { useApiService, type Note } from '../../services/api'; +import { useKeyboardHeight } from '../../hooks/useKeyboardHeight'; +import { useNoteEditor } from '../../hooks/useNoteEditor'; +import { EditorHeader } from './EditorHeader'; +import { EditorToolbar } from './EditorToolbar'; + +const NAVIGATION_DELAY = 100; + +export default function EditNoteScreen() { + const theme = useTheme(); + const api = useApiService(); + const router = useRouter(); + const params = useLocalSearchParams(); + const { noteId, folderId } = params; + const isEditing = !!noteId; + + const [title, setTitle] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [loading, setLoading] = useState(false); + const [noteData, setNoteData] = useState+ +editor.toggleBold()} + style={styles.toolbarButton} + > + + +B +editor.toggleItalic()} + style={styles.toolbarButton} + > + + +I +editor.toggleUnderline()} + style={styles.toolbarButton} + > + + +U ++ + editor.toggleHeading(1)} + style={styles.toolbarButton} + > + + +H1 +editor.toggleHeading(2)} + style={styles.toolbarButton} + > + + +H2 ++ + editor.toggleBulletList()} + style={styles.toolbarButton} + > + + +⢠+editor.toggleOrderedList()} + style={styles.toolbarButton} + > + + +1. +editor.toggleTaskList()} + style={styles.toolbarButton} + > + + ++ + + editor.undo()} + style={styles.toolbarButton} + > + + ++ editor.redo()} + style={styles.toolbarButton} + > + ++ (null); + + const keyboardHeight = useKeyboardHeight(); + const { editor, handleEditorLoad, loadNote } = useNoteEditor(noteId as string); + useEffect(() => { + if (isEditing && noteId) { + const load = async () => { + setLoading(true); + try { + const note = await loadNote(); + setNoteData(note || null); + setTitle(note?.title || ''); + } finally { + setLoading(false); + } + }; + load(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noteId, isEditing]); + + const handleSave = async () => { + if (!title.trim()) { + Alert.alert('Error', 'Please enter a title for your note'); + return; + } + + setIsSaving(true); + + try { + const content = await editor.getHTML(); + if (__DEV__) { + console.log('Content to save:', content); + } + + if (isEditing && noteId) { + await api.updateNote(noteId as string, { + title: title.trim(), + content, + }); + } else { + await api.createNote({ + title: title.trim(), + content, + folderId: folderId as string | undefined, + starred: false, + archived: false, + deleted: false, + }); + } + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + + setTimeout(() => { + router.back(); + }, NAVIGATION_DELAY); + } catch (error) { + if (__DEV__) console.error('Failed to save note:', error); + Alert.alert('Error', `Failed to ${isEditing ? 'update' : 'create'} note`); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!noteData || !noteId) return; + + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + Alert.alert( + 'Delete Note', + 'Are you sure you want to delete this note?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await api.deleteNote(noteId as string); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + + // Navigate back to the folder/notes list, skipping the view-note screen + if (router.canGoBack()) { + router.back(); // Go back from edit screen + setTimeout(() => { + if (router.canGoBack()) { + router.back(); // Go back from view screen to notes list + } + }, NAVIGATION_DELAY); + } + } catch (error) { + if (__DEV__) console.error('Failed to delete note:', error); + Alert.alert('Error', 'Failed to delete note'); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + }, + }, + ] + ); + }; + + if (loading) { + return ( + + + ); + } + + return ( ++ ++ Loading note... + ++ + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + titleContainer: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 0, + }, + titleInput: { + fontSize: 24, + fontWeight: '600', + marginBottom: 8, + lineHeight: 32, + padding: 0, + }, + metadata: { + gap: 4, + }, + date: { + fontSize: 12, + }, + divider: { + height: 0.5, + marginTop: 12, + }, + editorContainer: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + fontSize: 16, + }, +}); diff --git a/apps/mobile/v1/src/screens/EditNote/styles.ts b/apps/mobile/v1/src/screens/EditNote/styles.ts new file mode 100644 index 0000000..474d26b --- /dev/null +++ b/apps/mobile/v1/src/screens/EditNote/styles.ts @@ -0,0 +1,194 @@ +/** + * Generate custom CSS for the TenTap rich text editor + * Handles typography, task lists, code blocks, and theme colors + */ +export function generateEditorStyles(themeColors: { + background: string; + foreground: string; + mutedForeground: string; + muted: string; + primary: string; +}): string { + return ` + html { + overflow-x: hidden !important; + width: 100vw !important; + background-color: ${themeColors.background} !important; + } + *:not(input[type="checkbox"]):not(input[type="checkbox"]::after) { + color: ${themeColors.foreground} !important; + } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif !important; + font-size: 16px !important; + line-height: 1.5 !important; + background-color: ${themeColors.background} !important; + padding: 0 16px !important; + margin: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; + overscroll-behavior: contain !important; + width: 100vw !important; + max-width: 100vw !important; + } + .ProseMirror { + outline: none; + overflow-x: hidden !important; + width: calc(100vw - 32px) !important; + max-width: calc(100vw - 32px) !important; + line-height: 1.5 !important; + padding: 0 !important; + margin: 0 !important; + } + @media (min-width: 600px) { + .ProseMirror { + padding-bottom: 100px !important; + } + } + .ProseMirror > *:not(ul[data-type="taskList"]):not(ul[data-type="taskList"] *) { + max-width: 100% !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + white-space: pre-wrap !important; + } + p { + margin: 0 0 16px 0; + color: ${themeColors.foreground} !important; + } + p * { + color: ${themeColors.foreground} !important; + } + .ProseMirror > p:first-child { + margin-top: 12px !important; + } + li[data-type="taskItem"] > div > p:first-child { + margin-top: 0 !important; + } + h1, h2, h3, h4, h5, h6 { + margin: 24px 0 16px 0; + font-weight: 600; + color: ${themeColors.foreground} !important; + } + h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child { + margin-top: 12px; + } + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.25em; } + h1 *, h2 *, h3 *, h4 *, h5 *, h6 * { + color: ${themeColors.foreground} !important; + } + ul, ol { + margin: 16px 0; + padding-left: 24px; + } + li { + margin: 4px 0; + } + /* Task list styling - override TenTap defaults with higher specificity */ + ul[data-type="taskList"] { + list-style: none !important; + margin: 16px 0 !important; + padding: 0 !important; + } + + ul[data-type="taskList"] li { + display: flex !important; + align-items: flex-start !important; + margin: 8px 0 !important; + } + + ul[data-type="taskList"] li > label { + flex: 0 0 auto !important; + margin-right: 8px !important; + margin-top: 3px !important; + user-select: none !important; + display: flex !important; + align-items: center !important; + } + + ul[data-type="taskList"] li > label > input { + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + width: 18px !important; + height: 18px !important; + margin: 0 !important; + padding: 0 !important; + cursor: pointer !important; + border: 2px solid ${themeColors.mutedForeground} !important; + border-radius: 4px !important; + background: ${themeColors.background} !important; + flex-shrink: 0 !important; + } + + ul[data-type="taskList"] li > label > input:checked { + background: ${themeColors.background} !important; + border-color: ${themeColors.mutedForeground} !important; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='${encodeURIComponent(themeColors.foreground)}' stroke-width='2' d='M3 8l3 3 7-7'/%3E%3C/svg%3E") !important; + background-size: 14px 14px !important; + background-position: center !important; + background-repeat: no-repeat !important; + } + + ul[data-type="taskList"] li > div { + flex: 1 1 auto !important; + line-height: 1.5 !important; + font-size: 16px !important; + } + + ul[data-type="taskList"] p { + margin: 0 !important; + line-height: 1.5 !important; + color: ${themeColors.foreground} !important; + } + + li[data-type="taskItem"] * { + color: ${themeColors.foreground} !important; + } + a { + color: ${themeColors.primary}; + text-decoration: none; + } + blockquote { + border-left: 4px solid ${themeColors.primary}; + margin: 16px 0; + padding-left: 16px; + font-style: italic; + color: ${themeColors.mutedForeground}; + } + code { + background-color: ${themeColors.muted} !important; + color: ${themeColors.foreground} !important; + padding: 2px 4px; + border-radius: 4px; + font-family: 'Courier New', monospace; + } + pre { + background-color: ${themeColors.muted} !important; + color: ${themeColors.foreground} !important; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + max-width: 100%; + } + img, video, iframe { + max-width: 100%; + height: auto; + } + table { + max-width: 100%; + overflow-x: auto; + display: block; + } + strong { + font-weight: 600; + color: ${themeColors.foreground} !important; + } + em { + font-style: italic; + color: ${themeColors.foreground} !important; + } + `; +} diff --git a/apps/mobile/v1/src/screens/EditNoteScreen.tsx b/apps/mobile/v1/src/screens/EditNoteScreen.tsx new file mode 100644 index 0000000..252fa59 --- /dev/null +++ b/apps/mobile/v1/src/screens/EditNoteScreen.tsx @@ -0,0 +1,2 @@ +// Re-export from modularized version +export { default } from './EditNote'; diff --git a/apps/mobile/v1/src/screens/FoldersScreen.tsx b/apps/mobile/v1/src/screens/FoldersScreen.tsx new file mode 100644 index 0000000..dfb9a5d --- /dev/null +++ b/apps/mobile/v1/src/screens/FoldersScreen.tsx @@ -0,0 +1,798 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator, RefreshControl, Alert, Keyboard, Animated } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { useTheme } from '../theme'; +import { Ionicons } from '@expo/vector-icons'; +import { useApiService, type Folder } from '../services/api'; +import { FOLDER_CARD, ACTION_BUTTON, FOLDER_COLORS } from '../constants/ui'; +import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetTextInput } from '@gorhom/bottom-sheet'; + +interface Props { + navigation?: any; +} + +// Special views configuration matching web app +const SPECIAL_VIEWS = [ + { + id: 'all', + label: 'All Notes', + icon: 'document-text' as const, + }, + { + id: 'starred', + label: 'Starred', + icon: 'star' as const, + }, + { + id: 'archived', + label: 'Archived', + icon: 'archive' as const, + }, + { + id: 'trash', + label: 'Trash', + icon: 'trash' as const, + }, +]; + +// Helper function for time-based greeting +function getTimeOfDay(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'morning'; + if (hour < 17) return 'afternoon'; + return 'evening'; +} + +export default function FoldersScreen({ navigation }: Props) { + const theme = useTheme(); + const api = useApiService(); + const router = useRouter(); + const [allFolders, setAllFolders] = useStaterouter.back()} + onDelete={handleDelete} + onSave={handleSave} + theme={theme} + /> + + + + ++ + {isEditing && noteData && ( + + + )} + ++ Created {new Date(noteData.createdAt).toLocaleDateString()} + {noteData.updatedAt !== noteData.createdAt && + ` ⢠Updated ${new Date(noteData.updatedAt).toLocaleDateString()}`} + ++ + + ++ + ([]); + const [loading, setLoading] = useState(true); + const [showLoading, setShowLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [counts, setCounts] = useState({ + all: 0, + starred: 0, + archived: 0, + trash: 0, + }); + + // Create folder modal state + const [newFolderName, setNewFolderName] = useState(''); + const [selectedColor, setSelectedColor] = useState('#3b82f6'); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); + + // Bottom sheet ref + const createFolderSheetRef = useRef (null); + + // Scroll tracking for animated divider + const scrollY = useRef(new Animated.Value(0)).current; + + // Snap points + const snapPoints = useMemo(() => ['55%'], []); + + // Backdrop component + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + // Add keyboard listener to snap back to original position + useEffect(() => { + const keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + () => { + // Snap back to the original position when keyboard hides + createFolderSheetRef.current?.snapToIndex(0); + } + ); + + return () => { + keyboardDidHideListener.remove(); + }; + }, []); + + useEffect(() => { + loadFoldersData(); + loadViewMode(); + }, [api, loadFoldersData]); + + const loadViewMode = async () => { + try { + const savedMode = await AsyncStorage.getItem('viewMode'); + if (savedMode === 'grid' || savedMode === 'list') { + setViewMode(savedMode); + } + } catch (error) { + if (__DEV__) console.error('Failed to load view mode:', error); + } + }; + + // Handle loading delay + useEffect(() => { + let timer: NodeJS.Timeout; + if (loading) { + timer = setTimeout(() => setShowLoading(true), 300); + } else { + setShowLoading(false); + } + return () => clearTimeout(timer); + }, [loading]); + + const loadFoldersData = useCallback(async (isRefresh = false) => { + try { + if (!isRefresh) { + setLoading(true); + } + const [foldersData, allNotes] = await Promise.all([ + api.getFolders(), + api.getNotes() // Get all notes to calculate accurate counts + ]); + + if (__DEV__) { + console.log(`${isRefresh ? 'Refresh' : 'Initial'} load - Total notes: ${allNotes.length}`); + } + + // Show only ROOT folders (no parentId) on main screen + const rootFolders = foldersData.filter(folder => !folder.parentId); + + // Calculate note counts for each root folder including subfolders + const getFolderAndSubfolderIds = (folderId: string): string[] => { + const ids = [folderId]; + const subfolders = foldersData.filter(f => f.parentId === folderId); + subfolders.forEach(subfolder => { + ids.push(...getFolderAndSubfolderIds(subfolder.id)); + }); + return ids; + }; + + // Add note counts to root folders (including subfolder notes) + const rootFoldersWithCounts = rootFolders.map(folder => { + const allFolderIds = getFolderAndSubfolderIds(folder.id); + const folderNotes = allNotes.filter(note => + allFolderIds.includes(note.folderId || '') && + !note.deleted + ); + return { + ...folder, + noteCount: folderNotes.length + }; + }); + + setAllFolders(rootFoldersWithCounts); + + // Calculate accurate counts from all notes - using web app field names + const trashedNotes = allNotes.filter(note => note.deleted); + const archivedNotes = allNotes.filter(note => note.archived && !note.deleted); + const starredNotes = allNotes.filter(note => note.starred && !note.deleted && !note.archived); + const activeNotes = allNotes.filter(note => !note.deleted && !note.archived); + + const newCounts = { + all: activeNotes.length, + starred: starredNotes.length, + archived: archivedNotes.length, + trash: trashedNotes.length, + }; + + if (__DEV__) { + console.log('Updated note counts:', newCounts, 'Total notes loaded:', allNotes.length); + } + setCounts(newCounts); + } catch (error) { + if (__DEV__) console.error('Failed to load folders data:', error); + setAllFolders([]); + setCounts({ all: 0, starred: 0, archived: 0, trash: 0 }); + } finally { + if (!isRefresh) { + setLoading(false); + } + } + }, [api]); + + const onRefresh = async () => { + try { + setRefreshing(true); + // Clear current data to force fresh load + setAllFolders([]); + setCounts({ all: 0, starred: 0, archived: 0, trash: 0 }); + await loadFoldersData(true); + } finally { + setRefreshing(false); + } + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + Alert.alert('Error', 'Please enter a folder name.'); + return; + } + + try { + setIsCreatingFolder(true); + const createdFolder = await api.createFolder(newFolderName.trim(), selectedColor); + + // Add the new folder to the list + setAllFolders(prev => [...prev, { ...createdFolder, noteCount: 0 }]); + + // Reset modal state + setNewFolderName(''); + setSelectedColor('#3b82f6'); + createFolderSheetRef.current?.dismiss(); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (error) { + if (__DEV__) console.error('Failed to create folder:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Error', 'Failed to create folder. Please try again.'); + } finally { + setIsCreatingFolder(false); + } + }; + + return ( + + {showLoading ? ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + scrollView: { + flex: 1, + }, + scrollViewContent: { + paddingTop: 16, + }, + headerSection: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: 32, + paddingHorizontal: 16, + }, + greetingSection: { + flex: 1, + }, + greeting: { + fontSize: 28, + fontWeight: '600', + marginBottom: 4, + }, + subgreeting: { + fontSize: 16, + lineHeight: 22, + }, + avatarButton: { + padding: 4, + }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + quickActionsSection: { + marginBottom: 40, + paddingHorizontal: 16, + }, + newNoteAction: { + flexDirection: 'row', + alignItems: 'center', + padding: ACTION_BUTTON.PADDING, + borderRadius: ACTION_BUTTON.BORDER_RADIUS, + borderWidth: 1, + }, + actionIcon: { + marginRight: ACTION_BUTTON.ICON_SPACING, + }, + actionText: { + fontSize: ACTION_BUTTON.TEXT_SIZE, + fontWeight: '500', + }, + section: { + marginBottom: 32, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '500', + letterSpacing: 0.8, + marginBottom: 12, + paddingHorizontal: 16, + }, + viewModeToggle: { + flexDirection: 'row', + gap: 4, + }, + viewModeButton: { + width: 32, + height: 32, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + viewModeButtonActive: { + backgroundColor: 'rgba(59, 130, 246, 0.1)', + }, + specialViewsList: { + gap: FOLDER_CARD.SPACING, + paddingHorizontal: 16, + }, + specialViewItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: FOLDER_CARD.PADDING, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + }, + specialViewContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + specialViewIcon: { + marginRight: FOLDER_CARD.PADDING, + }, + specialViewLabel: { + fontSize: FOLDER_CARD.NAME_SIZE, + fontWeight: '500', + }, + countBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + minWidth: 24, + alignItems: 'center', + }, + countText: { + fontSize: FOLDER_CARD.COUNT_SIZE, + fontWeight: '500', + }, + foldersList: { + gap: FOLDER_CARD.SPACING, + paddingHorizontal: 16, + }, + foldersGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + paddingHorizontal: 16, + }, + folderItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: FOLDER_CARD.PADDING, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + }, + folderItemGrid: { + width: '48%', + padding: 16, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + minHeight: 100, + }, + folderContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + folderContentGrid: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }, + folderColorDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: FOLDER_CARD.PADDING, + }, + folderName: { + fontSize: FOLDER_CARD.NAME_SIZE, + fontWeight: '500', + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 64, + }, + emptyText: { + fontSize: 16, + textAlign: 'center', + }, + modalContainer: { + flex: 1, + justifyContent: 'flex-end', + }, + fullScreenBottomSheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '70%', + paddingBottom: 20, + }, + bottomSheetHandle: { + alignItems: 'center', + paddingVertical: 12, + }, + handleBar: { + width: 40, + height: 4, + borderRadius: 2, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 0, + paddingBottom: 12, + }, + divider: { + height: 0.5, + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + }, + iconButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, + modalBody: { + paddingHorizontal: 20, + paddingTop: 16, + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + colorGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginTop: 8, + }, + colorOption: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, + colorOptionSelected: { + borderWidth: 3, + borderColor: '#ffffff', + }, + modalFooter: { + paddingHorizontal: 20, + }, + createButton: { + width: '100%', + padding: 14, + borderRadius: 8, + alignItems: 'center', + }, + createButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/apps/mobile/v1/src/screens/NotesListScreen.tsx b/apps/mobile/v1/src/screens/NotesListScreen.tsx new file mode 100644 index 0000000..bd5773d --- /dev/null +++ b/apps/mobile/v1/src/screens/NotesListScreen.tsx @@ -0,0 +1,997 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { View, Text, ScrollView, StyleSheet, Pressable, Alert, ActivityIndicator, TouchableOpacity, RefreshControl, Animated, Keyboard } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; +import { useFocusEffect } from '@react-navigation/native'; +import { useTheme } from '../theme'; +import { useApiService, type Note, type Folder } from '../services/api'; +import { Ionicons } from '@expo/vector-icons'; +import { NOTE_CARD, FOLDER_CARD, SECTION, FOLDER_COLORS } from '../constants/ui'; +import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetTextInput } from '@gorhom/bottom-sheet'; + +interface Props { + navigation?: any; + route?: any; + renderHeader?: () => React.ReactNode; + scrollY?: Animated.Value; +} + +// Helper function to strip HTML tags and decode entities +function stripHtmlTags(html: string): string { + if (!html) return ''; + + // Remove HTML tags (apply repeatedly to handle nested/incomplete tags) + let previous; + let text = html; + do { + previous = text; + text = text.replace(/<[^>]*>/g, ''); + } while (text !== previous); + + // Decode common HTML entities (decode & last to prevent double-unescaping) + text = text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&'); + + // Remove extra whitespace and normalize + text = text.replace(/\s+/g, ' ').trim(); + + return text; +} + +export default function NotesListScreen({ navigation, route, renderHeader, scrollY: parentScrollY }: Props) { + const theme = useTheme(); + const api = useApiService(); + const { folderId, viewType, searchQuery } = route?.params || {}; + + const [notes, setNotes] = useState+ + ) : ( ++ + } + > + {!loading && ( + <> + {/* Header with Greeting and Avatar */} + + + + {/* Quick Actions */} ++ ++ Good {getTimeOfDay()} + ++ What would you like to work on today? + +router.push('/settings')} + > + ++ ++ + + + {/* Special Views Section */} +router.push('/edit-note')} + > + ++ ++ Start writing ++ + + {/* Folders Section */} ++ QUICK ACCESS + ++ {SPECIAL_VIEWS.map((view) => ( + +{ + router.push({ + pathname: '/folder-notes', + params: { viewType: view.id } + }); + }} + > + + ))} ++ ++ ++ + {view.label} + ++ ++ {counts[view.id as keyof typeof counts] || 0} + ++ + > + )} + + )} + + {/*+ ++ FOLDERS + ++ +{ + setViewMode('list'); + try { + await AsyncStorage.setItem('viewMode', 'list'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + ++ { + setViewMode('grid'); + try { + await AsyncStorage.setItem('viewMode', 'grid'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + ++ + {/* Create Folder Button */} + +createFolderSheetRef.current?.present()} + > + + + {allFolders.map((folder) => ( ++ ++ + Create Folder + +{ + router.push({ + pathname: '/folder-notes', + params: { folderId: folder.id, folderName: folder.name } + }); + }} + > + + ))} ++ {viewMode === 'list' && ( + + {viewMode === 'list' && ( ++ )} + + {folder.name} + ++ + )} ++ {folder.noteCount || 0} + +*/} + + {/* Create Folder Bottom Sheet */} + + ++ ++ +Create Folder +createFolderSheetRef.current?.dismiss()} + > + ++ + + + + +Folder Name ++ + Color ++ {FOLDER_COLORS.map((color) => ( + +setSelectedColor(color)} + > + {selectedColor === color && ( + + ))} ++ )} + + ++ ++ {isCreatingFolder ? 'Creating...' : 'Create'} + +([]); + const [subfolders, setSubfolders] = useState ([]); + const [allFolders, setAllFolders] = useState ([]); + const [loading, setLoading] = useState(true); + const [showLoading, setShowLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // Create folder modal state + const [newFolderName, setNewFolderName] = useState(''); + const [selectedColor, setSelectedColor] = useState('#3b82f6'); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); + + // Bottom sheet ref + const createFolderSheetRef = useRef (null); + const scrollViewRef = useRef (null); + + // Snap points + const snapPoints = useMemo(() => ['55%'], []); + + // Backdrop component + const renderBackdrop = useCallback( + (props: any) => ( + + )} + + + {/* Create Folder Bottom Sheet */} ++ ), + [] + ); + + // Add keyboard listener to snap back to original position + useEffect(() => { + const keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + () => { + // Snap back to the original position when keyboard hides + createFolderSheetRef.current?.snapToIndex(0); + } + ); + + return () => { + keyboardDidHideListener.remove(); + }; + }, []); + + // Scroll tracking for animated divider (use parent's scrollY if provided) + const localScrollY = useRef(new Animated.Value(0)).current; + const scrollY = parentScrollY || localScrollY; + + // Handle loading delay + useEffect(() => { + let timer: NodeJS.Timeout; + if (loading) { + timer = setTimeout(() => setShowLoading(true), 300); + } else { + setShowLoading(false); + } + return () => clearTimeout(timer); + }, [loading]); + + // Load notes when screen focuses + useFocusEffect( + React.useCallback(() => { + loadNotes(); + loadViewMode(); + // Reset scroll position when screen comes into focus + if (scrollViewRef.current) { + scrollViewRef.current.scrollTo({ y: 0, animated: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + ); + + const loadViewMode = async () => { + try { + const savedMode = await AsyncStorage.getItem('viewMode'); + if (savedMode === 'grid' || savedMode === 'list') { + setViewMode(savedMode); + } + } catch (error) { + if (__DEV__) console.error('Failed to load view mode:', error); + } + }; + + const loadNotes = async (isRefresh = false) => { + try { + if (!isRefresh) { + setLoading(true); + } + if (__DEV__) { + console.log('šÆ Loading notes for:', { folderId, viewType }); + } + + const [allNotesData, foldersData] = await Promise.all([ + api.getNotes(), // Get all notes, filter client-side + api.getFolders() // Load all folders to find subfolders + ]); + + if (__DEV__) { + console.log('š All notes received:', allNotesData.length); + } + + // Client-side filtering + let filteredNotes = allNotesData; + + // First filter by folder if specified + if (folderId) { + filteredNotes = filteredNotes.filter(note => note.folderId === folderId); + if (__DEV__) { + console.log('š After folder filter:', filteredNotes.length); + } + } + + // Then filter by view type + if (viewType) { + switch (viewType) { + case 'all': + filteredNotes = filteredNotes.filter(note => !note.deleted && !note.archived); + break; + case 'starred': + filteredNotes = filteredNotes.filter(note => note.starred && !note.deleted && !note.archived); + break; + case 'archived': + filteredNotes = filteredNotes.filter(note => note.archived && !note.deleted); + break; + case 'trash': + filteredNotes = filteredNotes.filter(note => note.deleted); + break; + } + } else if (folderId) { + // Regular folder view: exclude deleted and archived + filteredNotes = filteredNotes.filter(note => !note.deleted && !note.archived); + } + + if (__DEV__) { + console.log('ā Final filtered notes:', filteredNotes.length); + } + + setNotes(filteredNotes); + + // Find subfolders + let currentFolderSubfolders: Folder[]; + + if (folderId) { + // If viewing a specific folder, show its subfolders + currentFolderSubfolders = foldersData.filter(folder => folder.parentId === folderId); + } else { + // Don't show folders in special views (all, starred, archived, trash) + currentFolderSubfolders = []; + } + + // Recursive function to get all nested folder IDs + const getAllNestedFolderIds = (parentFolderId: string): string[] => { + const ids = [parentFolderId]; + const childFolders = foldersData.filter(f => f.parentId === parentFolderId); + childFolders.forEach(childFolder => { + ids.push(...getAllNestedFolderIds(childFolder.id)); + }); + return ids; + }; + + // Add note counts to subfolders (including nested subfolder notes) + const subfoldersWithCounts = currentFolderSubfolders.map(folder => { + const allNestedFolderIds = getAllNestedFolderIds(folder.id); + // Use allNotesData (not notesData) to get accurate counts + const folderNotes = allNotesData.filter(note => + allNestedFolderIds.includes(note.folderId || '') && + !note.deleted && + !note.archived + ); + + return { + ...folder, + noteCount: folderNotes.length + }; + }); + setSubfolders(subfoldersWithCounts); + + // Store all folders for looking up note folder info + setAllFolders(foldersData); + } catch (error) { + if (__DEV__) console.error('Failed to load notes:', error); + Alert.alert('Error', 'Failed to load notes. Please try again.'); + setNotes([]); + setSubfolders([]); + } finally { + if (!isRefresh) { + setLoading(false); + } + } + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + Alert.alert('Error', 'Please enter a folder name.'); + return; + } + + try { + setIsCreatingFolder(true); + const createdFolder = await api.createFolder(newFolderName.trim(), selectedColor, folderId); + + // Add the new folder to the subfolders list + setSubfolders(prev => [...prev, { ...createdFolder, noteCount: 0 }]); + + // Reset modal state + setNewFolderName(''); + setSelectedColor('#3b82f6'); + createFolderSheetRef.current?.dismiss(); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (error) { + if (__DEV__) console.error('Failed to create folder:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Error', 'Failed to create folder. Please try again.'); + } finally { + setIsCreatingFolder(false); + } + }; + + const onRefresh = async () => { + try { + setRefreshing(true); + await loadNotes(true); + } finally { + setRefreshing(false); + } + }; + + const handleEmptyTrash = async () => { + const deletedNotes = notes.filter(note => note.deleted); + + Alert.alert( + 'Empty Trash', + `Are you sure you want to permanently delete all ${deletedNotes.length} notes in the trash? This action cannot be undone.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Empty Trash', + style: 'destructive', + onPress: async () => { + try { + // Empty the trash using the API + const result = await api.emptyTrash(); + + // Reload notes to update the view + await loadNotes(true); + + Alert.alert('Success', `${result.deletedCount} notes permanently deleted.`); + } catch (error) { + if (__DEV__) console.error('Failed to empty trash:', error); + Alert.alert('Error', 'Failed to empty trash. Please try again.'); + } + }, + }, + ] + ); + }; + + const filteredNotes = notes.filter( + note => + note.title.toLowerCase().includes((searchQuery || '').toLowerCase()) || + note.content.toLowerCase().includes((searchQuery || '').toLowerCase()) + ); + + return ( + + + {showLoading ? ( + + + ) : ( ++ + } + > + {!loading && ( + <> + {/* Header from parent */} + {renderHeader && renderHeader()} + + {/* Subfolders Section */} + {!viewType && ( + + + )} + + {/* Notes Section */} ++ + ++ FOLDERS ({String(subfolders?.length || 0)}) + ++ +{ + setViewMode('list'); + try { + await AsyncStorage.setItem('viewMode', 'list'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + ++ { + setViewMode('grid'); + try { + await AsyncStorage.setItem('viewMode', 'grid'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + ++ + {/* Create Folder Button */} + +createFolderSheetRef.current?.present()} + > + + + {subfolders.map((subfolder) => ( ++ ++ + Create Folder + +navigation?.navigate('Notes', { folderId: subfolder.id, folderName: subfolder.name })} + > + + ))} ++ {viewMode === 'list' && ( + + {viewMode === 'list' && ( ++ )} + + {subfolder.name} + ++ + )} ++ {String(subfolder.noteCount || 0)} + ++ + > + )} +0 ? 12 : 16 }]}> + NOTES ({String(filteredNotes?.length || 0)}) + + + {/* Create Note Button / Empty Trash Button */} + {viewType === 'trash' ? ( + filteredNotes.length > 0 && ( ++ + ) + ) : ( ++ ++ ++ + Empty Trash + ++ + )} + + {filteredNotes.length === 0 && searchQuery ? ( +navigation?.navigate('CreateNote', { folderId: route?.params?.folderId })} + > + ++ ++ + Create Note + ++ + ) : ( ++ No notes found. Try a different search term. + ++ {filteredNotes.map((note, index) => { + const noteFolder = allFolders.find(f => f.id === note.folderId); + + // Build folder path + const getFolderPath = (folder: typeof noteFolder): string => { + if (!folder) return ''; + const path: string[] = []; + let currentFolder = folder; + + while (currentFolder) { + path.unshift(currentFolder.name); + if (currentFolder.parentId) { + currentFolder = allFolders.find(f => f.id === currentFolder.parentId); + } else { + break; + } + } + + return path.join(' / '); + }; + + const folderPath = getFolderPath(noteFolder); + const isLastNote = index === filteredNotes.length - 1; + + return ( + + )} ++ + ); + })} +navigation?.navigate('ViewNote', { noteId: note.id })} + style={({ pressed }) => [ + styles.noteListItem, + { backgroundColor: pressed ? theme.colors.muted : 'transparent' } + ]} + > + + {!isLastNote &&+ ++ ++ {String(note.title || 'Untitled')} + ++ {note.starred && ( + ++ )} + + {String(new Date(note.createdAt || Date.now()).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }))} + ++ {note.hidden ? '[HIDDEN]' : String(stripHtmlTags(note.content || ''))} + + {!folderId && folderPath && ( ++ + )} ++ + {folderPath} + +} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + animatedDivider: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 0.5, + zIndex: 1000, + }, + loadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + scrollView: { + flex: 1, + paddingHorizontal: 0, + paddingTop: 0, + }, + noteCardContainer: { + marginBottom: NOTE_CARD.SPACING, + paddingHorizontal: 16, + }, + noteCardContent: { + padding: NOTE_CARD.PADDING, + }, + noteTitleRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: 8, + }, + noteTitle: { + fontSize: NOTE_CARD.TITLE_SIZE, + fontWeight: '600', + flex: 1, + lineHeight: 22, + letterSpacing: -0.2, + }, + starIcon: { + marginLeft: 12, + marginTop: 1, + }, + noteContent: { + fontSize: NOTE_CARD.PREVIEW_SIZE, + lineHeight: 21, + opacity: 0.7, + marginBottom: 12, + }, + separator: { + height: 1, + marginBottom: 10, + marginHorizontal: -NOTE_CARD.PADDING, + opacity: 0.3, + }, + timestamp: { + fontSize: NOTE_CARD.TIMESTAMP_SIZE, + opacity: 0.5, + fontWeight: '400', + }, + noteCardFooter: { + flexDirection: 'column', + gap: 6, + }, + noteFolderInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + noteFolderDot: { + width: 8, + height: 8, + borderRadius: 2, + }, + noteFolderPath: { + fontSize: 12, + opacity: 0.6, + fontWeight: '400', + flex: 1, + }, + createNoteButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: FOLDER_CARD.PADDING, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + }, + emptySearchState: { + alignItems: 'center', + paddingVertical: 32, + }, + emptySearchText: { + fontSize: 14, + textAlign: 'center', + }, + // Subfolders styles + subfoldersSection: { + marginBottom: SECTION.SPACING, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '500', + letterSpacing: 0.8, + marginBottom: 12, + paddingHorizontal: 16, + }, + subfoldersList: { + gap: FOLDER_CARD.SPACING, + paddingHorizontal: 16, + }, + subfoldersGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + paddingHorizontal: 16, + }, + subfolderItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: FOLDER_CARD.PADDING, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + }, + subfolderItemGrid: { + width: '48%', + padding: 16, + borderRadius: FOLDER_CARD.BORDER_RADIUS, + borderWidth: 1, + minHeight: 100, + justifyContent: 'center', + alignItems: 'center', + }, + subfolderContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + subfolderContentGrid: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + subfolderColorDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 12, + }, + subfolderName: { + fontSize: FOLDER_CARD.NAME_SIZE, + fontWeight: '500', + }, + subfolderCountBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + minWidth: 24, + alignItems: 'center', + }, + subfolderCountText: { + fontSize: FOLDER_CARD.COUNT_SIZE, + fontWeight: '500', + }, + notesSection: { + paddingBottom: 16, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 16, + marginBottom: 12, + }, + viewModeToggle: { + flexDirection: 'row', + gap: 4, + }, + viewModeButton: { + width: 32, + height: 32, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + viewModeButtonActive: { + backgroundColor: 'rgba(59, 130, 246, 0.1)', + }, + // Grid view styles + notesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + paddingHorizontal: 16, + }, + noteGridItemWrapper: { + width: '48%', + }, + noteGridItem: { + borderRadius: 12, + borderWidth: 1, + padding: 12, + minHeight: 120, + }, + // List view styles + noteListItemWrapper: { + paddingHorizontal: 16, + }, + noteListItem: { + }, + noteListContent: { + paddingVertical: 12, + paddingHorizontal: 0, + }, + noteListHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + noteListTitle: { + fontSize: 17, + fontWeight: '600', + flex: 1, + marginRight: 8, + letterSpacing: -0.3, + }, + noteListMeta: { + flexDirection: 'row', + alignItems: 'center', + }, + noteListTime: { + fontSize: 14, + fontWeight: '400', + }, + noteListPreview: { + fontSize: 15, + lineHeight: 20, + marginBottom: 4, + }, + noteListFolderInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 4, + }, + noteListFolderDot: { + width: 8, + height: 8, + borderRadius: 2, + }, + noteListFolderPath: { + fontSize: 13, + fontWeight: '400', + }, + noteListDivider: { + height: StyleSheet.hairlineWidth, + marginLeft: 0, + }, + // Bottom sheet styles + bottomSheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 0, + paddingBottom: 12, + }, + bottomSheetTitle: { + fontSize: 18, + fontWeight: '600', + }, + iconButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, + divider: { + height: 0.5, + }, + bottomSheetBody: { + paddingHorizontal: 20, + paddingTop: 16, + marginBottom: 20, + }, + inputLabel: { + fontSize: 13, + fontWeight: '500', + marginBottom: 8, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + }, + colorGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginTop: 8, + }, + colorOption: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, + colorOptionSelected: { + borderWidth: 3, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + bottomSheetFooter: { + paddingHorizontal: 20, + }, + createButton: { + width: '100%', + padding: 14, + borderRadius: 8, + alignItems: 'center', + }, + createButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/apps/mobile/v1/src/screens/SettingsScreen.tsx b/apps/mobile/v1/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..e0bfcc0 --- /dev/null +++ b/apps/mobile/v1/src/screens/SettingsScreen.tsx @@ -0,0 +1,897 @@ +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert, Linking, Animated } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useUser } from '@clerk/clerk-expo'; +import { useRouter } from 'expo-router'; +import { useTheme } from '../theme'; +import { LIGHT_THEME_PRESETS, DARK_THEME_PRESETS } from '../theme/presets'; +import { Card } from '../components/ui/Card'; +import { Ionicons } from '@expo/vector-icons'; +import { clearUserEncryptionData } from '../lib/encryption'; +import { forceGlobalMasterPasswordRefresh } from '../hooks/useMasterPassword'; +import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +interface Props { + onLogout?: () => void; + navigation?: any; +} + +export default function SettingsScreen({ onLogout, navigation }: Props) { + const theme = useTheme(); + const { user } = useUser(); + const router = useRouter(); + + // Bottom sheet refs + const themeModeSheetRef = useRef+ ++ +Create Folder +createFolderSheetRef.current?.dismiss()} + > + ++ + + + + +Folder Name ++ + Color ++ {FOLDER_COLORS.map((color) => ( + +setSelectedColor(color)} + > + {selectedColor === color && ( + + ))} ++ )} + + ++ ++ {isCreatingFolder ? 'Creating...' : 'Create'} + +(null); + const themeColorSheetRef = useRef (null); + const securitySheetRef = useRef (null); + const viewModeSheetRef = useRef (null); + + // Scroll tracking + const scrollY = useRef(new Animated.Value(0)).current; + + // View mode state + const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); + + // Snap points + const themeModeSnapPoints = useMemo(() => ['45%'], []); + const themeColorSnapPoints = useMemo(() => ['80%'], []); + const securitySnapPoints = useMemo(() => ['70%'], []); + const viewModeSnapPoints = useMemo(() => ['40%'], []); + + // Backdrop component + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + + // Load view mode preference + useEffect(() => { + const loadViewMode = async () => { + try { + const savedMode = await AsyncStorage.getItem('viewMode'); + if (savedMode === 'grid' || savedMode === 'list') { + setViewMode(savedMode); + } + } catch (error) { + if (__DEV__) console.error('Failed to load view mode:', error); + } + }; + loadViewMode(); + }, []); + + const saveViewMode = async (mode: 'list' | 'grid') => { + try { + await AsyncStorage.setItem('viewMode', mode); + setViewMode(mode); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }; + + const handleResetMasterPassword = async () => { + Alert.alert( + 'Change Master Password', + 'This will clear your current master password and take you to set up a new one.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Change', + style: 'destructive', + onPress: async () => { + try { + if (user?.id) { + await clearUserEncryptionData(user.id); + + // Trigger global refresh of all master password hook instances + forceGlobalMasterPasswordRefresh(); + + // The global refresh should automatically show the master password screen + // No navigation needed since AppWrapper will detect the state change + } + } catch (error) { + Alert.alert('Error', 'Failed to change master password. Please try again.'); + if (__DEV__) console.error('Change master password error:', error); + } + } + } + ] + ); + }; + + const settingsItems = [ + { + section: 'SECURITY', + items: [ + { + title: 'Security', + subtitle: 'Learn how we protect your data', + icon: 'shield-checkmark-outline', + onPress: () => securitySheetRef.current?.present(), + }, + { + title: 'Change Master Password', + subtitle: 'Reset your encryption password', + icon: 'key-outline', + onPress: handleResetMasterPassword, + }, + { + title: 'Logout', + subtitle: 'Sign out of your account', + icon: 'log-out-outline', + isDestructive: true, + onPress: () => { + Alert.alert( + 'Logout', + 'Are you sure you want to logout? You'll need to enter your master password again.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Logout', style: 'destructive', onPress: onLogout } + ] + ); + }, + }, + ], + }, + { + section: 'DATA', + items: [ + { + title: 'Sync Status', + subtitle: 'Last synced: Just now', + icon: 'sync-outline', + onPress: undefined, + }, + ], + }, + { + section: 'PREFERENCES', + items: [ + { + title: 'View Mode', + subtitle: viewMode === 'list' ? 'List' : 'Grid', + icon: viewMode === 'list' ? 'list-outline' : 'grid-outline', + onPress: () => viewModeSheetRef.current?.present(), + }, + { + title: 'Theme Mode', + subtitle: theme.themeMode === 'system' ? 'System' : theme.themeMode === 'dark' ? 'Dark' : 'Light', + icon: theme.themeMode === 'system' ? 'phone-portrait-outline' : theme.isDark ? 'moon-outline' : 'sunny-outline', + onPress: () => themeModeSheetRef.current?.present(), + }, + { + title: 'Theme Colors', + subtitle: theme.isDark + ? DARK_THEME_PRESETS[theme.darkTheme].name + : LIGHT_THEME_PRESETS[theme.lightTheme].name, + icon: 'color-palette-outline', + onPress: () => themeColorSheetRef.current?.present(), + }, + ], + }, + { + section: 'ABOUT', + items: [ + { + title: 'Version', + subtitle: '1.0.0', + icon: 'information-circle-outline', + onPress: undefined, + }, + { + title: 'Open Source', + subtitle: 'View source code on GitHub', + icon: 'logo-github', + onPress: () => Linking.openURL('https://github.com/typelets/typelets-app'), + }, + { + title: "What's New", + subtitle: 'See latest updates and changes', + icon: 'newspaper-outline', + onPress: () => Linking.openURL('https://github.com/typelets/typelets-app/blob/main/CHANGELOG.md'), + }, + { + title: 'Support', + subtitle: 'Get help and report issues', + icon: 'help-circle-outline', + onPress: () => Linking.openURL('https://github.com/typelets/typelets-app/issues'), + }, + { + title: 'Privacy Policy', + subtitle: 'View our privacy policy', + icon: 'shield-outline', + onPress: () => Linking.openURL('https://typelets.com/privacy'), + }, + { + title: 'Terms of Service', + subtitle: 'View our terms of service', + icon: 'document-text-outline', + onPress: () => Linking.openURL('https://typelets.com/terms'), + }, + ], + }, + ]; + + // Animated divider opacity using interpolate + const dividerOpacity = scrollY.interpolate({ + inputRange: [0, 20], + outputRange: [0, 1], + extrapolate: 'clamp' + }); + + return ( + + {/* Header with back button */} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + headerBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 12, + minHeight: 44, + }, + headerTitle: { + flex: 1, + marginLeft: 12, + textAlign: 'left', + fontSize: 16, + fontWeight: '600', + }, + headerSpacer: { + width: 34, + }, + headerDivider: { + height: StyleSheet.hairlineWidth, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 16, + }, + sectionsContainer: { + paddingHorizontal: 16, + }, + section: { + marginBottom: 24, + }, + firstSection: { + marginTop: 0, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '500', + letterSpacing: 0.8, + marginBottom: 12, + paddingHorizontal: 4, + }, + firstSectionTitle: { + paddingTop: 8, + marginTop: 0, + }, + settingCardContainer: { + marginBottom: 8, + }, + settingItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + settingItemLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + settingItemText: { + flex: 1, + }, + settingItemTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 2, + }, + settingItemSubtitle: { + fontSize: 14, + }, + settingItemRight: { + marginLeft: 12, + }, + toggle: { + width: 44, + height: 24, + borderRadius: 12, + padding: 2, + justifyContent: 'center', + }, + toggleThumb: { + width: 20, + height: 20, + borderRadius: 10, + }, + // Bottom sheet modal styles + modalContainer: { + flex: 1, + justifyContent: 'flex-end', + }, + bottomSheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '70%', + }, + bottomSheetHandle: { + alignItems: 'center', + paddingVertical: 12, + }, + handleBar: { + width: 40, + height: 4, + borderRadius: 2, + }, + bottomSheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 0, + paddingBottom: 12, + }, + bottomSheetTitle: { + fontSize: 20, + fontWeight: '600', + }, + iconButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, + bottomSheetContent: { + paddingHorizontal: 20, + gap: 12, + }, + optionItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + borderWidth: 1, + }, + optionIcon: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + optionText: { + flex: 1, + }, + optionTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 2, + }, + optionSubtitle: { + fontSize: 14, + }, + divider: { + height: 0.5, + }, + bottomSheetScrollContent: { + paddingHorizontal: 20, + paddingTop: 8, + }, + themeCard: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + borderWidth: 1, + marginBottom: 12, + }, + themeOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + marginBottom: 1, + }, + themeOptionIcon: { + width: 36, + height: 36, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + themeOptionText: { + flex: 1, + }, + themeOptionTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 2, + }, + themeOptionSubtitle: { + fontSize: 13, + }, + securityFeatureTitle: { + fontSize: 16, + fontWeight: '600', + }, + securityFeatureDescription: { + fontSize: 14, + lineHeight: 20, + }, + lastThemeOption: { + borderBottomWidth: 0, + }, + colorPreview: { + width: 36, + height: 36, + borderRadius: 8, + marginRight: 12, + borderWidth: 1, + padding: 4, + alignItems: 'center', + justifyContent: 'center', + }, + colorPreviewInner: { + width: 20, + height: 20, + borderRadius: 4, + }, + radioButton: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + }, + radioButtonInner: { + width: 10, + height: 10, + borderRadius: 5, + }, + themeSection: { + marginBottom: 24, + }, + themeSectionLast: { + marginBottom: 32, + }, + themeSectionTitle: { + fontSize: 12, + fontWeight: '500', + letterSpacing: 0.8, + marginBottom: 12, + marginTop: 8, + }, +}); \ No newline at end of file diff --git a/apps/mobile/v1/src/screens/TestEditorScreen.tsx b/apps/mobile/v1/src/screens/TestEditorScreen.tsx new file mode 100644 index 0000000..99340c0 --- /dev/null +++ b/apps/mobile/v1/src/screens/TestEditorScreen.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { + SafeAreaView, + KeyboardAvoidingView, + Platform, + StyleSheet, +} from 'react-native'; +import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'; + +export default function TestEditorScreen() { + const editor = useEditorBridge({ + autofocus: true, + avoidIosKeyboard: true, + initialContent: '+ + ++ +router.back()} + > + ++ Settings ++ + + + + {/* Theme Mode Selection Bottom Sheet */} ++ {settingsItems.map((section, sectionIndex) => ( + ++ + ))} + ++ {section.section} + + + {section.items.map((item) => ( ++ + ))} ++ ++ ++ + ++ ++ + ++ {item.title} + ++ {item.subtitle} + ++ {item.toggle ? ( + ++ + ) : item.onPress ? ( ++ + ) : null} + + + + {/* Theme Color Selection Bottom Sheet */} ++ ++ ++ Theme Mode + +themeModeSheetRef.current?.dismiss()} + > + ++ + + {[ + { mode: 'light', title: 'Light', subtitle: 'Always use light theme', icon: 'sunny-outline' }, + { mode: 'dark', title: 'Dark', subtitle: 'Always use dark theme', icon: 'moon-outline' }, + { mode: 'system', title: 'System', subtitle: 'Follow system setting', icon: 'phone-portrait-outline' } + ].map((option) => ( + +{ + theme.setThemeMode(option.mode as any); + themeModeSheetRef.current?.dismiss(); + }} + > + + ))} ++ ++ + + {theme.themeMode === option.mode && ( ++ {option.title} + ++ {option.subtitle} + ++ )} + + + + {/* Security Information Bottom Sheet */} ++ ++ ++ Theme Colors + +themeColorSheetRef.current?.dismiss()} + > + ++ + + + {/* Light Themes Section */} + ++ + + {/* Dark Themes Section */} ++ LIGHT THEMES + + {Object.values(LIGHT_THEME_PRESETS).map((preset) => ( +{ + theme.setLightTheme(preset.id as any); + }} + > + + ))} ++ ++ + + {theme.lightTheme === preset.id && ( ++ {preset.name} + ++ {preset.description} + ++ )} + + ++ DARK THEMES + + {Object.values(DARK_THEME_PRESETS).map((preset) => ( +{ + theme.setDarkTheme(preset.id as any); + }} + > + + ))} ++ ++ + + {theme.darkTheme === preset.id && ( ++ {preset.name} + ++ {preset.description} + ++ )} + + + + {/* View Mode Selection Bottom Sheet */} ++ ++ + ++ Security & Privacy + +securitySheetRef.current?.dismiss()} + > + ++ + + + ++ ++ + ++ ++ + Zero-Knowledge Encryption + ++ Your notes are encrypted with AES-256 encryption using your master password. We never have access to your decryption key or unencrypted data. + ++ + ++ ++ + Master Password + ++ Your master password is the only key to decrypt your notes. It's never stored on our servers and cannot be recovered if lost. + ++ + ++ ++ + End-to-End Protection + ++ All encryption and decryption happens on your device. Your data is encrypted before it leaves your device and stays encrypted on our servers. + ++ ++ ++ + Complete Privacy + ++ We can't read your notes, recover your password, or access your data. Your privacy is guaranteed by design. + ++ + ++ ++ ++ View Mode + +viewModeSheetRef.current?.dismiss()} + > + ++ + + {[ + { mode: 'list', title: 'List', subtitle: 'View items in a list', icon: 'list-outline' }, + { mode: 'grid', title: 'Grid', subtitle: 'View items in a grid', icon: 'grid-outline' } + ].map((option) => ( + +{ + saveViewMode(option.mode as 'list' | 'grid'); + viewModeSheetRef.current?.dismiss(); + }} + > + + ))} ++ ++ + + {viewMode === option.mode && ( ++ {option.title} + ++ {option.subtitle} + ++ )} + This is a test - can you see the toolbar?
', + }); + + return ( ++ + ); +} + +const styles = StyleSheet.create({ + fullScreen: { + flex: 1, + }, + keyboardAvoidingView: { + position: 'absolute', + width: '100%', + bottom: 0, + }, +}); diff --git a/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx new file mode 100644 index 0000000..6288e37 --- /dev/null +++ b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Animated, useWindowDimensions } from 'react-native'; +import { WebView } from 'react-native-webview'; +import type { Note } from '../../services/api'; + +interface NoteContentProps { + note: Note; + htmlContent: string; + scrollY: Animated.Value; + scrollViewRef: React.RefObject+ + ++ ; + theme: { + colors: { + foreground: string; + mutedForeground: string; + border: string; + }; + }; +} +export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, theme }: NoteContentProps) { + const { height } = useWindowDimensions(); + const [webViewHeight, setWebViewHeight] = useState(height); + + const titleOpacity = scrollY.interpolate({ + inputRange: [0, 80], + outputRange: [1, 0], + extrapolate: 'clamp', + }); + + return ( + + + ); +} + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + titleContainer: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 0, + }, + title: { + fontSize: 24, + fontWeight: '600', + marginBottom: 8, + lineHeight: 32, + }, + metadata: { + gap: 4, + }, + date: { + fontSize: 12, + }, + divider: { + height: 0.5, + marginTop: 12, + }, + contentContainer: { + paddingTop: 0, + }, + webView: { + flex: 1, + backgroundColor: 'transparent', + }, + hiddenContainer: { + minHeight: 400, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + hiddenText: { + fontSize: 16, + fontWeight: '500', + }, +}); diff --git a/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx new file mode 100644 index 0000000..7136dda --- /dev/null +++ b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { View, StyleSheet, TouchableOpacity, Animated, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +interface ViewHeaderProps { + isStarred: boolean; + isHidden: boolean; + title: string; + scrollY: Animated.Value; + onBack: () => void; + onToggleStar: () => void; + onToggleHidden: () => void; + onEdit: () => void; + theme: { + colors: { + primary: string; + primaryForeground: string; + mutedForeground: string; + muted: string; + foreground: string; + border: string; + card: string; + secondary: string; + }; + isDark: boolean; + }; +} +export function ViewHeader({ + isStarred, + isHidden, + title, + scrollY, + onBack, + onToggleStar, + onToggleHidden, + onEdit, + theme, +}: ViewHeaderProps) { + const titleOpacity = scrollY.interpolate({ + inputRange: [40, 80], + outputRange: [0, 1], + extrapolate: 'clamp', + }); + + const dividerOpacity = scrollY.interpolate({ + inputRange: [0, 20], + outputRange: [0, 1], + extrapolate: 'clamp', + }); + + return ( + <> ++ + ++ {note.title} + + ++ + ++ Created {new Date(note.createdAt).toLocaleDateString()} + {note.updatedAt !== note.createdAt && + ` ⢠Updated ${new Date(note.updatedAt).toLocaleDateString()}`} + ++ + {note.hidden ? ( + + ) : ( + ++ + )} +{ + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type === 'height' && data.height) { + setWebViewHeight(Math.max(data.height, 200)); + } + } catch { + // Ignore parse errors + } + }} + /> + + ++ + ++ + + ++ {title} + ++ ++ + ++ + + ++ + ++ + > + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 12, + minHeight: 44, + }, + divider: { + height: StyleSheet.hairlineWidth, + }, + headerButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 17, + }, + titleContainer: { + flex: 1, + paddingHorizontal: 16, + justifyContent: 'center', + }, + headerTitle: { + fontSize: 17, + fontWeight: '600', + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + editButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 17, + }, +}); diff --git a/apps/mobile/v1/src/screens/ViewNote/htmlGenerator.ts b/apps/mobile/v1/src/screens/ViewNote/htmlGenerator.ts new file mode 100644 index 0000000..77d1ab1 --- /dev/null +++ b/apps/mobile/v1/src/screens/ViewNote/htmlGenerator.ts @@ -0,0 +1,123 @@ +/** + * Generate HTML content for WebView with proper styling + * Used for read-only note viewing with rich text formatting + */ +export function generateNoteHtml( + content: string, + themeColors: { + background: string; + foreground: string; + mutedForeground: string; + muted: string; + primary: string; + } +): string { + return ` + + + + + + + + ${content || ' No content
'} + + + `; +} diff --git a/apps/mobile/v1/src/screens/ViewNote/index.tsx b/apps/mobile/v1/src/screens/ViewNote/index.tsx new file mode 100644 index 0000000..3fa8c91 --- /dev/null +++ b/apps/mobile/v1/src/screens/ViewNote/index.tsx @@ -0,0 +1,104 @@ +import React, { useRef, useEffect } from 'react'; +import { View, Text, StyleSheet, Animated } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router'; +import { useTheme } from '../../theme'; +import { useViewNote } from '../../hooks/useViewNote'; +import { ViewHeader } from './ViewHeader'; +import { NoteContent } from './NoteContent'; + +export default function ViewNoteScreen() { + const theme = useTheme(); + const router = useRouter(); + const params = useLocalSearchParams(); + const { noteId } = params; + + const scrollY = useRef(new Animated.Value(0)).current; + const scrollViewRef = useRef(null); + + const { note, loading, htmlContent, handleEdit, handleToggleStar, handleToggleHidden } = useViewNote(noteId as string); + + useEffect(() => { + // Reset scroll position when note changes + scrollY.setValue(0); + if (scrollViewRef.current) { + scrollViewRef.current.scrollTo({ y: 0, animated: false }); + } + }, [noteId, scrollY]); + + useFocusEffect( + React.useCallback(() => { + // Reset scroll position when screen comes into focus + scrollY.setValue(0); + if (scrollViewRef.current) { + scrollViewRef.current.scrollTo({ y: 0, animated: false }); + } + }, [scrollY]) + ); + + if (loading) { + return ( + + + ); + } + + if (!note) { + return ( ++ ++ Loading note... + ++ + ); + } + + return ( ++ ++ Note not found + ++ + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + headerContainer: { + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + fontSize: 16, + }, +}); diff --git a/apps/mobile/v1/src/screens/ViewNoteScreen.tsx b/apps/mobile/v1/src/screens/ViewNoteScreen.tsx new file mode 100644 index 0000000..48ae930 --- /dev/null +++ b/apps/mobile/v1/src/screens/ViewNoteScreen.tsx @@ -0,0 +1,2 @@ +// Re-export from modularized version +export { default } from './ViewNote'; diff --git a/apps/mobile/v1/src/services/api/client.ts b/apps/mobile/v1/src/services/api/client.ts new file mode 100644 index 0000000..d987413 --- /dev/null +++ b/apps/mobile/v1/src/services/api/client.ts @@ -0,0 +1,79 @@ +/** + * Base HTTP Client + * Handles authentication and HTTP requests + */ + +import { API_BASE_URL } from './utils/constants'; +import { ApiError, getUserFriendlyErrorMessage } from './utils/errors'; + +export type AuthTokenGetter = () => Promise+ + +router.back()} + onToggleStar={handleToggleStar} + onToggleHidden={handleToggleHidden} + onEdit={handleEdit} + theme={theme} + /> + + ; + +/** + * Creates an HTTP client with authentication + */ +export function createHttpClient(getToken: AuthTokenGetter) { + /** + * Makes an authenticated API request + */ + async function makeRequest ( + endpoint: string, + options: RequestInit = {} + ): Promise { + try { + const token = await getToken(); + + const headers = { + 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }), + ...options.headers, + }; + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + + // Log detailed error for development + if (__DEV__) { + console.error('API Error Response:', { + status: response.status, + statusText: response.statusText, + body: errorText, + endpoint, + }); + } + + const userMessage = getUserFriendlyErrorMessage(response.status); + throw new ApiError(userMessage, response.status, errorText); + } + + const data = await response.json(); + return data as T; + } catch (error) { + // Log detailed error for development + if (__DEV__) { + console.error('API Request Failed:', { + endpoint, + error, + }); + } + + // Re-throw if already an ApiError + if (error instanceof ApiError) { + throw error; + } + + // Wrap other errors + throw new ApiError( + error instanceof Error ? error.message : 'Network request failed', + undefined, + error + ); + } + } + + return { makeRequest }; +} diff --git a/apps/mobile/v1/src/services/api/encryption.ts b/apps/mobile/v1/src/services/api/encryption.ts new file mode 100644 index 0000000..69a0f98 --- /dev/null +++ b/apps/mobile/v1/src/services/api/encryption.ts @@ -0,0 +1,134 @@ +/** + * Encryption Wrapper for API Service + * Handles note encryption/decryption logic + */ + +import { + decryptNoteData, + encryptNoteData, + isNoteEncrypted, + clearNoteCacheForUser, +} from '../../lib/encryption'; +import { Note } from './types'; +import { ENCRYPTED_MARKER } from './utils/constants'; + +/** + * Decrypts a note if it's encrypted + */ +export async function decryptNote(note: Note, userId: string): Promise { + if (!isNoteEncrypted(note)) { + return note; // Return as-is if not encrypted + } + + try { + const decrypted = await decryptNoteData( + userId, + note.encryptedTitle!, + note.encryptedContent!, + note.iv!, + note.salt! + ); + + return { + ...note, + title: decrypted.title, + content: decrypted.content, + }; + } catch (error) { + if (__DEV__) { + console.error('Failed to decrypt note:', note.id, error); + } + // Return note with placeholder content if decryption fails + return { + ...note, + title: '[Encrypted - Unable to decrypt]', + content: '[This note is encrypted but could not be decrypted. Please check your master password.]', + }; + } +} + +/** + * Decrypts an array of notes + */ +export async function decryptNotes( + notes: Note[], + userId: string +): Promise { + if (!notes.length) { + return notes; + } + + try { + // Decrypt notes individually to handle failures gracefully + const decryptedNotes = await Promise.all( + notes.map(async (note) => { + try { + return await decryptNote(note, userId); + } catch { + // Return note with fallback content if decryption fails + return { + ...note, + title: note.title || '[Encrypted - Unable to decrypt]', + content: note.content || '[This note could not be decrypted]', + }; + } + }) + ); + return decryptedNotes; + } catch (error) { + if (__DEV__) { + console.error('Error during note decryption batch:', error); + } + return notes; // Return original notes if batch decryption fails + } +} + +/** + * Encrypts note data for API submission + */ +export async function encryptNoteForApi( + userId: string, + title: string, + content: string +): Promise<{ + title: string; + content: string; + encryptedTitle: string; + encryptedContent: string; + iv: string; + salt: string; +}> { + // Safety check: don't encrypt if already encrypted + if (title === ENCRYPTED_MARKER || content === ENCRYPTED_MARKER) { + throw new Error('Attempted to encrypt already encrypted content - this would corrupt the note'); + } + + // Encrypt the note data + const encryptedData = await encryptNoteData(userId, title, content); + + // Validate encryption worked + if ( + !encryptedData.encryptedTitle || + !encryptedData.encryptedContent || + !encryptedData.iv || + !encryptedData.salt + ) { + throw new Error('Failed to encrypt note data'); + } + + return { + title: ENCRYPTED_MARKER, + content: ENCRYPTED_MARKER, + encryptedTitle: encryptedData.encryptedTitle, + encryptedContent: encryptedData.encryptedContent, + iv: encryptedData.iv, + salt: encryptedData.salt, + }; +} + +/** + * Clears encryption cache for a user + */ +export function clearEncryptionCache(userId: string): void { + clearNoteCacheForUser(userId); +} diff --git a/apps/mobile/v1/src/services/api/folders.ts b/apps/mobile/v1/src/services/api/folders.ts new file mode 100644 index 0000000..0989d88 --- /dev/null +++ b/apps/mobile/v1/src/services/api/folders.ts @@ -0,0 +1,75 @@ +/** + * Folders API Module + * Handles all folder-related API operations + */ + +import { createHttpClient, AuthTokenGetter } from './client'; +import { Folder, FoldersResponse } from './types'; +import { fetchAllPages, createPaginationParams } from './utils/pagination'; +import { handleApiError } from './utils/errors'; + +export function createFoldersApi(getToken: AuthTokenGetter) { + const { makeRequest } = createHttpClient(getToken); + + return { + /** + * Get all folders with pagination + */ + async getFolders(): Promise { + try { + const allFolders = await fetchAllPages ( + async (page) => { + const params = createPaginationParams(page); + return await makeRequest (`/folders?${params.toString()}`); + }, + (response) => response.folders || [] + ); + + return allFolders; + } catch (error) { + return handleApiError(error, 'getFolders'); + } + }, + + /** + * Create a new folder + */ + async createFolder(name: string, color: string, parentId?: string): Promise { + try { + return await makeRequest ('/folders', { + method: 'POST', + body: JSON.stringify({ name, color, parentId }), + }); + } catch (error) { + return handleApiError(error, 'createFolder'); + } + }, + + /** + * Update a folder + */ + async updateFolder(folderId: string, updates: Partial ): Promise { + try { + return await makeRequest (`/folders/${folderId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } catch (error) { + return handleApiError(error, 'updateFolder'); + } + }, + + /** + * Delete a folder + */ + async deleteFolder(folderId: string): Promise { + try { + await makeRequest (`/folders/${folderId}`, { + method: 'DELETE', + }); + } catch (error) { + return handleApiError(error, 'deleteFolder'); + } + }, + }; +} diff --git a/apps/mobile/v1/src/services/api/index.ts b/apps/mobile/v1/src/services/api/index.ts new file mode 100644 index 0000000..c16b9c4 --- /dev/null +++ b/apps/mobile/v1/src/services/api/index.ts @@ -0,0 +1,45 @@ +/** + * API Service Main Entry Point + * Exports the main useApiService hook and types + */ + +import { useAuth, useUser } from '@clerk/clerk-expo'; +import { createNotesApi } from './notes'; +import { createFoldersApi } from './folders'; + +// Re-export types for convenience +export type { Folder, Note, NoteQueryParams, EmptyTrashResponse } from './types'; + +/** + * Main API service hook + * Provides access to all API methods with authentication + */ +export const useApiService = () => { + const { getToken } = useAuth(); + const { user } = useUser(); + + const getUserId = () => user?.id; + + // Create API modules + const notesApi = createNotesApi(getToken, getUserId); + const foldersApi = createFoldersApi(getToken); + + // Return combined API surface + return { + // Notes methods + getNotes: notesApi.getNotes, + getNote: notesApi.getNote, + createNote: notesApi.createNote, + updateNote: notesApi.updateNote, + deleteNote: notesApi.deleteNote, + hideNote: notesApi.hideNote, + unhideNote: notesApi.unhideNote, + emptyTrash: notesApi.emptyTrash, + + // Folders methods + getFolders: foldersApi.getFolders, + createFolder: foldersApi.createFolder, + updateFolder: foldersApi.updateFolder, + deleteFolder: foldersApi.deleteFolder, + }; +}; diff --git a/apps/mobile/v1/src/services/api/notes.ts b/apps/mobile/v1/src/services/api/notes.ts new file mode 100644 index 0000000..9954451 --- /dev/null +++ b/apps/mobile/v1/src/services/api/notes.ts @@ -0,0 +1,210 @@ +/** + * Notes API Module + * Handles all note-related API operations + */ + +import { createHttpClient, AuthTokenGetter } from './client'; +import { Note, NoteQueryParams, NotesResponse, EmptyTrashResponse } from './types'; +import { decryptNote, decryptNotes, encryptNoteForApi, clearEncryptionCache } from './encryption'; +import { fetchAllPages, createPaginationParams } from './utils/pagination'; +import { handleApiError } from './utils/errors'; + +export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => string | undefined) { + const { makeRequest } = createHttpClient(getToken); + + return { + /** + * Get all notes with optional filters + */ + async getNotes(params?: NoteQueryParams): Promise { + try { + const userId = getUserId(); + + const allNotes = await fetchAllPages ( + async (page) => { + const searchParams = createPaginationParams(page, params as Record ); + return await makeRequest (`/notes?${searchParams.toString()}`); + }, + (response) => response.notes || [] + ); + + // Decrypt notes if we have a user ID + if (userId && allNotes.length > 0) { + return await decryptNotes(allNotes, userId); + } + + return allNotes; + } catch (error) { + return handleApiError(error, 'getNotes'); + } + }, + + /** + * Get a single note by ID + */ + async getNote(noteId: string): Promise { + try { + const userId = getUserId(); + const note = await makeRequest (`/notes/${noteId}`); + + // Decrypt note if we have a user ID + if (userId) { + return await decryptNote(note, userId); + } + + return note; + } catch (error) { + return handleApiError(error, 'getNote'); + } + }, + + /** + * Create a new note + */ + async createNote(note: Omit ): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required for note creation'); + } + + const title = note.title || 'Untitled'; + const content = note.content || ''; + + // Encrypt the note data + const encryptedData = await encryptNoteForApi(userId, title, content); + + // Create the payload with encrypted data + const notePayload = { + ...note, + ...encryptedData, + }; + + const createdNote = await makeRequest ('/notes', { + method: 'POST', + body: JSON.stringify(notePayload), + }); + + // Clear encryption cache since we created a new note + clearEncryptionCache(userId); + + // Decrypt and return the created note + return await decryptNote(createdNote, userId); + } catch (error) { + if (__DEV__) { + console.error('Failed to create note:', error); + } + throw error; + } + }, + + /** + * Update an existing note + */ + async updateNote(noteId: string, updates: Partial ): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required for note update'); + } + + let updatePayload: Partial = { ...updates }; + + // If updating title or content, encrypt them + if (updates.title !== undefined || updates.content !== undefined) { + const title = updates.title || ''; + const content = updates.content || ''; + + const encryptedData = await encryptNoteForApi(userId, title, content); + + updatePayload = { + ...updates, + ...encryptedData, + }; + } + + const updatedNote = await makeRequest (`/notes/${noteId}`, { + method: 'PUT', + body: JSON.stringify(updatePayload), + }); + + // Clear encryption cache since we updated a note + clearEncryptionCache(userId); + + // Decrypt and return the updated note + return await decryptNote(updatedNote, userId); + } catch (error) { + if (__DEV__) { + console.error('Failed to update note:', error); + } + throw error; + } + }, + + /** + * Delete a note + */ + async deleteNote(noteId: string): Promise { + try { + await makeRequest (`/notes/${noteId}`, { + method: 'DELETE', + }); + } catch (error) { + return handleApiError(error, 'deleteNote'); + } + }, + + /** + * Hide a note + */ + async hideNote(noteId: string): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required'); + } + + const note = await makeRequest (`/notes/${noteId}/hide`, { + method: 'POST', + }); + + return await decryptNote(note, userId); + } catch (error) { + return handleApiError(error, 'hideNote'); + } + }, + + /** + * Unhide a note + */ + async unhideNote(noteId: string): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required'); + } + + const note = await makeRequest (`/notes/${noteId}/unhide`, { + method: 'POST', + }); + + return await decryptNote(note, userId); + } catch (error) { + return handleApiError(error, 'unhideNote'); + } + }, + + /** + * Empty trash (permanently delete all trashed notes) + */ + async emptyTrash(): Promise { + try { + return await makeRequest ('/notes/empty-trash', { + method: 'DELETE', + }); + } catch (error) { + return handleApiError(error, 'emptyTrash'); + } + }, + }; +} diff --git a/apps/mobile/v1/src/services/api/types.ts b/apps/mobile/v1/src/services/api/types.ts new file mode 100644 index 0000000..f18d1a1 --- /dev/null +++ b/apps/mobile/v1/src/services/api/types.ts @@ -0,0 +1,85 @@ +/** + * API Type Definitions + * Shared types for the API service + */ + +export interface Folder { + id: string; + name: string; + color: string; + parentId?: string; + userId: string; + createdAt: string; + updatedAt: string; + children?: Folder[]; + noteCount?: number; + sortOrder?: number; + isDefault?: boolean; +} + +export interface Note { + id: string; + title: string; + content: string; + folderId?: string; + userId: string; + starred: boolean; + archived?: boolean; + deleted?: boolean; + hidden: boolean; + hiddenAt?: string | null; + createdAt: string; + updatedAt: string; + // Encrypted fields (if note is encrypted) + encryptedTitle?: string; + encryptedContent?: string; + iv?: string; + salt?: string; +} + +export interface PaginatedResponse { + data: T[]; + pagination?: { + total: number; + limit: number; + page: number; + totalPages: number; + }; + total?: number; + limit?: number; +} + +export interface NotesResponse { + notes: Note[]; + pagination?: { + total: number; + limit: number; + page: number; + totalPages: number; + }; + total?: number; + limit?: number; +} + +export interface FoldersResponse { + folders: Folder[]; + pagination?: { + total: number; + limit: number; + page: number; + totalPages: number; + }; +} + +export interface NoteQueryParams { + folderId?: string; + starred?: boolean; + archived?: boolean; + deleted?: boolean; + hidden?: boolean; +} + +export interface EmptyTrashResponse { + message: string; + deletedCount: number; +} diff --git a/apps/mobile/v1/src/services/api/utils/constants.ts b/apps/mobile/v1/src/services/api/utils/constants.ts new file mode 100644 index 0000000..979cf87 --- /dev/null +++ b/apps/mobile/v1/src/services/api/utils/constants.ts @@ -0,0 +1,19 @@ +/** + * API Constants + */ + +export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.typelets.com/api'; + +export const DEFAULT_PAGE_LIMIT = 50; +export const MAX_PAGES = 50; // Safety limit to prevent infinite loops +export const PAGINATION_DELAY_MS = 100; // Delay between paginated requests + +export const ENCRYPTED_MARKER = '[ENCRYPTED]'; + +// Log API configuration in development +if (__DEV__) { + console.log('=== API SERVICE CONFIG ==='); + console.log('process.env.EXPO_PUBLIC_API_URL:', process.env.EXPO_PUBLIC_API_URL); + console.log('Final API_BASE_URL:', API_BASE_URL); + console.log('========================'); +} diff --git a/apps/mobile/v1/src/services/api/utils/errors.ts b/apps/mobile/v1/src/services/api/utils/errors.ts new file mode 100644 index 0000000..18d7c5e --- /dev/null +++ b/apps/mobile/v1/src/services/api/utils/errors.ts @@ -0,0 +1,50 @@ +/** + * API Error Handling Utilities + */ + +export class ApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public originalError?: unknown + ) { + super(message); + this.name = 'ApiError'; + } +} + +export function getUserFriendlyErrorMessage(status: number): string { + switch (status) { + case 401: + return 'Authentication failed. Please log in again.'; + case 403: + return 'You do not have permission to perform this action.'; + case 404: + return 'The requested resource was not found.'; + case 429: + return 'Too many requests. Please wait a moment and try again.'; + case 500: + case 502: + case 503: + case 504: + return 'Server error. Please try again later.'; + default: + return 'Something went wrong. Please try again.'; + } +} + +export function handleApiError(error: unknown, context: string): never { + if (__DEV__) { + console.error(`API Error (${context}):`, error); + } + + if (error instanceof ApiError) { + throw error; + } + + if (error instanceof Error) { + throw new ApiError(error.message, undefined, error); + } + + throw new ApiError('An unexpected error occurred', undefined, error); +} diff --git a/apps/mobile/v1/src/services/api/utils/pagination.ts b/apps/mobile/v1/src/services/api/utils/pagination.ts new file mode 100644 index 0000000..a18c552 --- /dev/null +++ b/apps/mobile/v1/src/services/api/utils/pagination.ts @@ -0,0 +1,106 @@ +/** + * Pagination Utilities + * Reusable pagination logic for API requests + */ + +import { DEFAULT_PAGE_LIMIT, MAX_PAGES, PAGINATION_DELAY_MS } from './constants'; + +interface PaginationResponse { + data: T[]; + pagination?: { + total: number; + limit: number; + page: number; + totalPages: number; + }; + total?: number; + limit?: number; +} + +/** + * Fetches all pages of data using pagination + * @param fetchPage - Function that fetches a single page of data + * @param extractData - Function that extracts the data array from the response + * @returns Array of all items across all pages + */ +export async function fetchAllPages ( + fetchPage: (page: number) => Promise , + extractData: (response: TResponse) => TItem[] +): Promise { + const allItems: TItem[] = []; + let page = 1; + let hasMore = true; + + while (hasMore && page <= MAX_PAGES) { + const response = await fetchPage(page); + const pageItems = extractData(response); + allItems.push(...pageItems); + + // Determine if there are more pages + hasMore = determineHasMorePages(response, pageItems.length); + + if (hasMore) { + page++; + // Add delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, PAGINATION_DELAY_MS)); + } else { + break; + } + } + + if (page > MAX_PAGES) { + if (__DEV__) { + console.warn(`Reached maximum page limit (${MAX_PAGES})`); + } + } + + return allItems; +} + +/** + * Determines if there are more pages based on the response structure + */ +function determineHasMorePages ( + response: T, + pageItemsCount: number +): boolean { + const resp = response as PaginationResponse ; + + // Check for pagination object + if (resp.pagination) { + const { page, totalPages } = resp.pagination; + return page < totalPages; + } + + // Check for total/limit properties + if (resp.total !== undefined && resp.limit !== undefined) { + // Assuming current page can be inferred from items fetched + return pageItemsCount >= resp.limit; + } + + // Fallback: assume more pages if we got a full page + return pageItemsCount >= DEFAULT_PAGE_LIMIT; +} + +/** + * Creates URL search params with pagination + */ +export function createPaginationParams( + page: number, + additionalParams?: Record +): URLSearchParams { + const params = new URLSearchParams(); + params.append('page', page.toString()); + params.append('limit', DEFAULT_PAGE_LIMIT.toString()); + params.append('_t', Date.now().toString()); // Cache busting + + if (additionalParams) { + Object.entries(additionalParams).forEach(([key, value]) => { + if (value !== undefined) { + params.append(key, String(value)); + } + }); + } + + return params; +} diff --git a/apps/mobile/v1/src/theme/colors.ts b/apps/mobile/v1/src/theme/colors.ts new file mode 100644 index 0000000..98775e2 --- /dev/null +++ b/apps/mobile/v1/src/theme/colors.ts @@ -0,0 +1,52 @@ +// Typelets color scheme for React Native +export const colors = { + border: 'hsl(214.3, 31.8%, 91.4%)', + input: 'hsl(214.3, 31.8%, 91.4%)', + ring: 'hsl(222.2, 84%, 4.9%)', + background: 'hsl(0, 0%, 100%)', + foreground: 'hsl(222.2, 84%, 4.9%)', + primary: { + DEFAULT: 'hsl(222.2, 47.4%, 11.2%)', + foreground: 'hsl(210, 40%, 98%)', + }, + secondary: { + DEFAULT: 'hsl(210, 40%, 96%)', + foreground: 'hsl(222.2, 84%, 4.9%)', + }, + destructive: { + DEFAULT: 'hsl(0, 84.2%, 60.2%)', + foreground: 'hsl(210, 40%, 98%)', + }, + muted: { + DEFAULT: 'hsl(210, 40%, 96%)', + foreground: 'hsl(215.4, 16.3%, 46.9%)', + }, + accent: { + DEFAULT: 'hsl(210, 40%, 96%)', + foreground: 'hsl(222.2, 84%, 4.9%)', + }, + popover: { + DEFAULT: 'hsl(0, 0%, 100%)', + foreground: 'hsl(222.2, 84%, 4.9%)', + }, + card: { + DEFAULT: 'hsl(0, 0%, 100%)', + foreground: 'hsl(222.2, 84%, 4.9%)', + }, + // Simplified versions for easier access + white: '#ffffff', + black: '#000000', + blue: '#3b82f6', + gray: { + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', + }, +}; \ No newline at end of file diff --git a/apps/mobile/v1/src/theme/index.tsx b/apps/mobile/v1/src/theme/index.tsx new file mode 100644 index 0000000..0955d3a --- /dev/null +++ b/apps/mobile/v1/src/theme/index.tsx @@ -0,0 +1,247 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { useColorScheme } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LIGHT_THEME_PRESETS, DARK_THEME_PRESETS, type LightThemePreset, type DarkThemePreset } from './presets'; + +export type ThemeMode = 'light' | 'dark' | 'system'; +export type { LightThemePreset, DarkThemePreset }; + +export const theme = { + colors: { + light: { + background: '#ffffff', + foreground: '#25262b', + card: '#ffffff', + cardForeground: '#25262b', + primary: '#343a46', + primaryForeground: '#fcfcfc', + secondary: '#f5f5f5', + secondaryForeground: '#343a46', + muted: '#f5f5f5', + mutedForeground: '#8e8e93', + accent: '#f5f5f5', + accentForeground: '#343a46', + destructive: '#dc3545', + destructiveForeground: '#fcfcfc', + border: '#C0C0C0', + input: '#EBEBEB', + ring: '#b5b5b5', + }, + dark: { + background: '#252525', + foreground: '#fcfcfc', + card: '#353535', + cardForeground: '#fcfcfc', + primary: '#ebebeb', + primaryForeground: '#353535', + secondary: '#454545', + secondaryForeground: '#fcfcfc', + muted: '#454545', + mutedForeground: '#b5b5b5', + accent: '#454545', + accentForeground: '#fcfcfc', + destructive: '#b91c1c', + destructiveForeground: '#fcfcfc', + border: 'rgba(255, 255, 255, 0.1)', + input: 'rgba(255, 255, 255, 0.15)', + ring: '#8e8e93', + } + }, + spacing: { + xs: 2, + sm: 4, + md: 8, + lg: 12, + xl: 16, + '2xl': 24, + }, + typography: { + fontSize: { + xs: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + '2xl': 24, + '3xl': 30, + }, + fontWeight: { + normal: '400', + medium: '500', + semibold: '600', + bold: '700', + }, + }, + borderRadius: { + sm: 4, + md: 6, + lg: 2, + xl: 12, + }, +}; + +export type Theme = typeof theme; + +const ThemeContext = createContext<{ + colors: typeof theme.colors.light; + spacing: typeof theme.spacing; + typography: typeof theme.typography; + borderRadius: typeof theme.borderRadius; + isDark: boolean; + themeMode: ThemeMode; + lightTheme: LightThemePreset; + darkTheme: DarkThemePreset; + setThemeMode: (mode: ThemeMode) => void; + setLightTheme: (preset: LightThemePreset) => void; + setDarkTheme: (preset: DarkThemePreset) => void; + toggleTheme: () => void; +} | null>(null); + +// Hook for using theme in components +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + // Fallback to light theme if no provider + return { + colors: theme.colors.light, + spacing: theme.spacing, + typography: theme.typography, + borderRadius: theme.borderRadius, + isDark: false, + themeMode: 'system' as ThemeMode, + lightTheme: 'default' as LightThemePreset, + darkTheme: 'trueBlack' as DarkThemePreset, + setThemeMode: () => {}, + setLightTheme: () => {}, + setDarkTheme: () => {}, + toggleTheme: () => {}, + }; + } + return context; +}; + +// Theme Provider Component +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const systemColorScheme = useColorScheme(); + const [themeMode, setThemeModeState] = useState ('system'); + const [lightTheme, setLightThemeState] = useState ('default'); + const [darkTheme, setDarkThemeState] = useState ('trueBlack'); + const [isDark, setIsDark] = useState(systemColorScheme === 'dark'); + const [isLoaded, setIsLoaded] = useState(false); + + // Load saved preferences on mount + useEffect(() => { + const loadPreferences = async () => { + try { + const [savedMode, savedLightTheme, savedDarkTheme] = await Promise.all([ + AsyncStorage.getItem('themeMode'), + AsyncStorage.getItem('lightTheme'), + AsyncStorage.getItem('darkTheme'), + ]); + + if (savedMode) { + setThemeModeState(savedMode as ThemeMode); + } + if (savedLightTheme) { + setLightThemeState(savedLightTheme as LightThemePreset); + } + if (savedDarkTheme) { + setDarkThemeState(savedDarkTheme as DarkThemePreset); + } + } catch (error) { + if (__DEV__) console.error('Failed to load theme preferences:', error); + } finally { + setIsLoaded(true); + } + }; + + loadPreferences(); + }, []); + + // Update isDark when system color scheme or theme mode changes + useEffect(() => { + switch (themeMode) { + case 'light': + setIsDark(false); + break; + case 'dark': + setIsDark(true); + break; + case 'system': + setIsDark(systemColorScheme === 'dark'); + break; + } + }, [themeMode, systemColorScheme]); + + // Persist theme mode changes + const setThemeMode = async (mode: ThemeMode) => { + try { + await AsyncStorage.setItem('themeMode', mode); + setThemeModeState(mode); + } catch (error) { + if (__DEV__) console.error('Failed to save theme mode:', error); + } + }; + + // Persist light theme changes + const setLightTheme = async (preset: LightThemePreset) => { + try { + await AsyncStorage.setItem('lightTheme', preset); + setLightThemeState(preset); + } catch (error) { + if (__DEV__) console.error('Failed to save light theme:', error); + } + }; + + // Persist dark theme changes + const setDarkTheme = async (preset: DarkThemePreset) => { + try { + await AsyncStorage.setItem('darkTheme', preset); + setDarkThemeState(preset); + } catch (error) { + if (__DEV__) console.error('Failed to save dark theme:', error); + } + }; + + // Legacy toggle function for backwards compatibility + const toggleTheme = () => { + switch (themeMode) { + case 'light': + setThemeMode('dark'); + break; + case 'dark': + setThemeMode('system'); + break; + case 'system': + setThemeMode('light'); + break; + } + }; + + // Get current color scheme from preset + const currentColors = isDark + ? DARK_THEME_PRESETS[darkTheme].colors + : LIGHT_THEME_PRESETS[lightTheme].colors; + + const value = { + colors: currentColors, + spacing: theme.spacing, + typography: theme.typography, + borderRadius: theme.borderRadius, + isDark, + themeMode, + lightTheme, + darkTheme, + setThemeMode, + setLightTheme, + setDarkTheme, + toggleTheme, + }; + + // Don't render until preferences are loaded + if (!isLoaded) { + return null; + } + + return {children} ; +}; \ No newline at end of file diff --git a/apps/mobile/v1/src/theme/presets.ts b/apps/mobile/v1/src/theme/presets.ts new file mode 100644 index 0000000..e325455 --- /dev/null +++ b/apps/mobile/v1/src/theme/presets.ts @@ -0,0 +1,425 @@ +/** + * Theme color presets for customizable app themes + */ + +export type LightThemePreset = 'default' | 'warmWhite' | 'coolGray' | 'lavender' | 'mint' | 'rose' | 'peach' | 'sky'; +export type DarkThemePreset = 'trueBlack' | 'defaultDark' | 'deepBlue' | 'darkPurple' | 'forest' | 'darkRed' | 'darkOrange' | 'darkTeal'; + +interface ColorScheme { + background: string; + foreground: string; + card: string; + cardForeground: string; + primary: string; + primaryForeground: string; + secondary: string; + secondaryForeground: string; + muted: string; + mutedForeground: string; + accent: string; + accentForeground: string; + destructive: string; + destructiveForeground: string; + border: string; + input: string; + ring: string; +} + +export interface ThemePreset { + id: string; + name: string; + description: string; + colors: ColorScheme; +} + +// Light theme presets +export const LIGHT_THEME_PRESETS: Record= { + default: { + id: 'default', + name: 'Default', + description: 'Classic clean white', + colors: { + background: '#ffffff', + foreground: '#25262b', + card: '#ffffff', + cardForeground: '#25262b', + primary: '#343a46', + primaryForeground: '#fcfcfc', + secondary: '#f5f5f5', + secondaryForeground: '#343a46', + muted: '#f5f5f5', + mutedForeground: '#8e8e93', + accent: '#f5f5f5', + accentForeground: '#343a46', + destructive: '#dc3545', + destructiveForeground: '#fcfcfc', + border: '#C0C0C0', + input: '#EBEBEB', + ring: '#b5b5b5', + }, + }, + warmWhite: { + id: 'warmWhite', + name: 'Warm White', + description: 'Cream tint, easy on eyes', + colors: { + background: '#faf9f6', + foreground: '#25262b', + card: '#f5f5f0', + cardForeground: '#25262b', + primary: '#343a46', + primaryForeground: '#faf9f6', + secondary: '#eeeee8', + secondaryForeground: '#343a46', + muted: '#eeeee8', + mutedForeground: '#8e8e93', + accent: '#eeeee8', + accentForeground: '#343a46', + destructive: '#dc3545', + destructiveForeground: '#faf9f6', + border: '#d5d5c8', + input: '#e8e8e0', + ring: '#b5b5b5', + }, + }, + coolGray: { + id: 'coolGray', + name: 'Cool Gray', + description: 'Modern Notion-style', + colors: { + background: '#f8f9fa', + foreground: '#25262b', + card: '#f1f3f5', + cardForeground: '#25262b', + primary: '#343a46', + primaryForeground: '#f8f9fa', + secondary: '#e9ecef', + secondaryForeground: '#343a46', + muted: '#e9ecef', + mutedForeground: '#8e8e93', + accent: '#e9ecef', + accentForeground: '#343a46', + destructive: '#dc3545', + destructiveForeground: '#f8f9fa', + border: '#d0d4d8', + input: '#e5e8eb', + ring: '#b5b5b5', + }, + }, + lavender: { + id: 'lavender', + name: 'Lavender', + description: 'Soft purple tones', + colors: { + background: '#faf8ff', + foreground: '#25262b', + card: '#f5f3ff', + cardForeground: '#25262b', + primary: '#7c3aed', + primaryForeground: '#ffffff', + secondary: '#ede9fe', + secondaryForeground: '#5b21b6', + muted: '#ede9fe', + mutedForeground: '#8e8e93', + accent: '#ede9fe', + accentForeground: '#5b21b6', + destructive: '#dc3545', + destructiveForeground: '#ffffff', + border: '#ddd6fe', + input: '#e9e5fc', + ring: '#a78bfa', + }, + }, + mint: { + id: 'mint', + name: 'Mint', + description: 'Fresh green accents', + colors: { + background: '#f7fef9', + foreground: '#25262b', + card: '#f0fdf4', + cardForeground: '#25262b', + primary: '#059669', + primaryForeground: '#ffffff', + secondary: '#d1fae5', + secondaryForeground: '#065f46', + muted: '#d1fae5', + mutedForeground: '#8e8e93', + accent: '#d1fae5', + accentForeground: '#065f46', + destructive: '#dc3545', + destructiveForeground: '#ffffff', + border: '#a7f3d0', + input: '#d1fae5', + ring: '#6ee7b7', + }, + }, + rose: { + id: 'rose', + name: 'Rose', + description: 'Soft pink blush', + colors: { + background: '#fff5f7', + foreground: '#25262b', + card: '#ffe4e9', + cardForeground: '#25262b', + primary: '#e11d48', + primaryForeground: '#ffffff', + secondary: '#fecdd3', + secondaryForeground: '#881337', + muted: '#fecdd3', + mutedForeground: '#8e8e93', + accent: '#fecdd3', + accentForeground: '#881337', + destructive: '#dc3545', + destructiveForeground: '#ffffff', + border: '#fda4af', + input: '#fecdd3', + ring: '#fb7185', + }, + }, + peach: { + id: 'peach', + name: 'Peach', + description: 'Warm coral tones', + colors: { + background: '#fff7ed', + foreground: '#25262b', + card: '#ffedd5', + cardForeground: '#25262b', + primary: '#ea580c', + primaryForeground: '#ffffff', + secondary: '#fed7aa', + secondaryForeground: '#7c2d12', + muted: '#fed7aa', + mutedForeground: '#8e8e93', + accent: '#fed7aa', + accentForeground: '#7c2d12', + destructive: '#dc3545', + destructiveForeground: '#ffffff', + border: '#fdba74', + input: '#fed7aa', + ring: '#fb923c', + }, + }, + sky: { + id: 'sky', + name: 'Sky', + description: 'Bright blue horizon', + colors: { + background: '#f0f9ff', + foreground: '#25262b', + card: '#e0f2fe', + cardForeground: '#25262b', + primary: '#0284c7', + primaryForeground: '#ffffff', + secondary: '#bae6fd', + secondaryForeground: '#075985', + muted: '#bae6fd', + mutedForeground: '#8e8e93', + accent: '#bae6fd', + accentForeground: '#075985', + destructive: '#dc3545', + destructiveForeground: '#ffffff', + border: '#7dd3fc', + input: '#bae6fd', + ring: '#38bdf8', + }, + }, +}; + +// Dark theme presets +export const DARK_THEME_PRESETS: Record = { + trueBlack: { + id: 'trueBlack', + name: 'True Black', + description: 'OLED-friendly pure black', + colors: { + background: '#0a0a0a', + foreground: '#fcfcfc', + card: '#1a1a1a', + cardForeground: '#fcfcfc', + primary: '#ebebeb', + primaryForeground: '#1a1a1a', + secondary: '#2a2a2a', + secondaryForeground: '#fcfcfc', + muted: '#2a2a2a', + mutedForeground: '#b5b5b5', + accent: '#2a2a2a', + accentForeground: '#fcfcfc', + destructive: '#b91c1c', + destructiveForeground: '#fcfcfc', + border: 'rgba(255, 255, 255, 0.1)', + input: 'rgba(255, 255, 255, 0.15)', + ring: '#8e8e93', + }, + }, + defaultDark: { + id: 'defaultDark', + name: 'Dark Gray', + description: 'Balanced dark gray', + colors: { + background: '#252525', + foreground: '#fcfcfc', + card: '#353535', + cardForeground: '#fcfcfc', + primary: '#ebebeb', + primaryForeground: '#353535', + secondary: '#454545', + secondaryForeground: '#fcfcfc', + muted: '#454545', + mutedForeground: '#b5b5b5', + accent: '#454545', + accentForeground: '#fcfcfc', + destructive: '#b91c1c', + destructiveForeground: '#fcfcfc', + border: 'rgba(255, 255, 255, 0.1)', + input: 'rgba(255, 255, 255, 0.15)', + ring: '#8e8e93', + }, + }, + deepBlue: { + id: 'deepBlue', + name: 'Deep Blue', + description: 'Dark blue undertones', + colors: { + background: '#0f1419', + foreground: '#e3e8ef', + card: '#1a1f2e', + cardForeground: '#e3e8ef', + primary: '#60a5fa', + primaryForeground: '#0f1419', + secondary: '#1e293b', + secondaryForeground: '#e3e8ef', + muted: '#1e293b', + mutedForeground: '#94a3b8', + accent: '#1e293b', + accentForeground: '#e3e8ef', + destructive: '#ef4444', + destructiveForeground: '#ffffff', + border: 'rgba(96, 165, 250, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#60a5fa', + }, + }, + darkPurple: { + id: 'darkPurple', + name: 'Dark Purple', + description: 'Rich purple hues', + colors: { + background: '#1a0f2e', + foreground: '#f3e8ff', + card: '#2a1f3d', + cardForeground: '#f3e8ff', + primary: '#a78bfa', + primaryForeground: '#1a0f2e', + secondary: '#3b2a54', + secondaryForeground: '#f3e8ff', + muted: '#3b2a54', + mutedForeground: '#c4b5fd', + accent: '#3b2a54', + accentForeground: '#f3e8ff', + destructive: '#f43f5e', + destructiveForeground: '#ffffff', + border: 'rgba(167, 139, 250, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#a78bfa', + }, + }, + forest: { + id: 'forest', + name: 'Forest', + description: 'Deep green ambiance', + colors: { + background: '#0a1409', + foreground: '#e8f5e9', + card: '#1a2d19', + cardForeground: '#e8f5e9', + primary: '#4ade80', + primaryForeground: '#0a1409', + secondary: '#1e3a1c', + secondaryForeground: '#e8f5e9', + muted: '#1e3a1c', + mutedForeground: '#86efac', + accent: '#1e3a1c', + accentForeground: '#e8f5e9', + destructive: '#f87171', + destructiveForeground: '#ffffff', + border: 'rgba(74, 222, 128, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#4ade80', + }, + }, + darkRed: { + id: 'darkRed', + name: 'Dark Red', + description: 'Deep crimson accents', + colors: { + background: '#1a0a0a', + foreground: '#ffe8e8', + card: '#2a1414', + cardForeground: '#ffe8e8', + primary: '#f87171', + primaryForeground: '#1a0a0a', + secondary: '#3a1e1e', + secondaryForeground: '#ffe8e8', + muted: '#3a1e1e', + mutedForeground: '#fca5a5', + accent: '#3a1e1e', + accentForeground: '#ffe8e8', + destructive: '#ef4444', + destructiveForeground: '#ffffff', + border: 'rgba(248, 113, 113, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#f87171', + }, + }, + darkOrange: { + id: 'darkOrange', + name: 'Dark Orange', + description: 'Warm amber tones', + colors: { + background: '#1a0f05', + foreground: '#fff5e8', + card: '#2a1f14', + cardForeground: '#fff5e8', + primary: '#fb923c', + primaryForeground: '#1a0f05', + secondary: '#3a2f1e', + secondaryForeground: '#fff5e8', + muted: '#3a2f1e', + mutedForeground: '#fdba74', + accent: '#3a2f1e', + accentForeground: '#fff5e8', + destructive: '#f87171', + destructiveForeground: '#ffffff', + border: 'rgba(251, 146, 60, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#fb923c', + }, + }, + darkTeal: { + id: 'darkTeal', + name: 'Dark Teal', + description: 'Cool cyan depths', + colors: { + background: '#0a1414', + foreground: '#e8ffff', + card: '#14292a', + cardForeground: '#e8ffff', + primary: '#2dd4bf', + primaryForeground: '#0a1414', + secondary: '#1e3a3a', + secondaryForeground: '#e8ffff', + muted: '#1e3a3a', + mutedForeground: '#5eead4', + accent: '#1e3a3a', + accentForeground: '#e8ffff', + destructive: '#f87171', + destructiveForeground: '#ffffff', + border: 'rgba(45, 212, 191, 0.15)', + input: 'rgba(255, 255, 255, 0.1)', + ring: '#2dd4bf', + }, + }, +}; diff --git a/apps/mobile/v1/tsconfig.json b/apps/mobile/v1/tsconfig.json new file mode 100644 index 0000000..909e901 --- /dev/null +++ b/apps/mobile/v1/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] +} diff --git a/design-tokens/colors.json b/design-tokens/colors.json new file mode 100644 index 0000000..f914007 --- /dev/null +++ b/design-tokens/colors.json @@ -0,0 +1,82 @@ +{ + "colors": { + "light": { + "background": "hsl(0, 0%, 100%)", + "foreground": "hsl(222.2, 84%, 4.9%)", + "card": "hsl(0, 0%, 100%)", + "cardForeground": "hsl(222.2, 84%, 4.9%)", + "popover": "hsl(0, 0%, 100%)", + "popoverForeground": "hsl(222.2, 84%, 4.9%)", + "primary": "hsl(222.2, 47.4%, 11.2%)", + "primaryForeground": "hsl(210, 40%, 98%)", + "secondary": "hsl(210, 40%, 96%)", + "secondaryForeground": "hsl(222.2, 84%, 4.9%)", + "muted": "hsl(210, 40%, 96%)", + "mutedForeground": "hsl(215.4, 16.3%, 46.9%)", + "accent": "hsl(210, 40%, 96%)", + "accentForeground": "hsl(222.2, 84%, 4.9%)", + "destructive": "hsl(0, 84.2%, 60.2%)", + "destructiveForeground": "hsl(210, 40%, 98%)", + "border": "hsl(214.3, 31.8%, 91.4%)", + "input": "hsl(214.3, 31.8%, 91.4%)", + "ring": "hsl(222.2, 84%, 4.9%)" + }, + "dark": { + "background": "hsl(222.2, 84%, 4.9%)", + "foreground": "hsl(210, 40%, 98%)", + "card": "hsl(222.2, 84%, 4.9%)", + "cardForeground": "hsl(210, 40%, 98%)", + "popover": "hsl(222.2, 84%, 4.9%)", + "popoverForeground": "hsl(210, 40%, 98%)", + "primary": "hsl(210, 40%, 98%)", + "primaryForeground": "hsl(222.2, 47.4%, 11.2%)", + "secondary": "hsl(217.2, 32.6%, 17.5%)", + "secondaryForeground": "hsl(210, 40%, 98%)", + "muted": "hsl(217.2, 32.6%, 17.5%)", + "mutedForeground": "hsl(215, 20.2%, 65.1%)", + "accent": "hsl(217.2, 32.6%, 17.5%)", + "accentForeground": "hsl(210, 40%, 98%)", + "destructive": "hsl(0, 62.8%, 30.6%)", + "destructiveForeground": "hsl(210, 40%, 98%)", + "border": "hsl(217.2, 32.6%, 17.5%)", + "input": "hsl(217.2, 32.6%, 17.5%)", + "ring": "hsl(212.7, 26.8%, 83.9%)" + } + }, + "spacing": { + "xs": 4, + "sm": 8, + "md": 16, + "lg": 24, + "xl": 32, + "2xl": 48 + }, + "typography": { + "fontSizes": { + "xs": 12, + "sm": 14, + "base": 16, + "lg": 18, + "xl": 20, + "2xl": 24, + "3xl": 30 + }, + "fontWeights": { + "normal": "400", + "medium": "500", + "semibold": "600", + "bold": "700" + } + }, + "borderRadius": { + "sm": 4, + "md": 6, + "lg": 8, + "xl": 12 + }, + "shadows": { + "sm": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "md": "0 4px 6px -1px rgb(0 0 0 / 0.1)", + "lg": "0 10px 15px -3px rgb(0 0 0 / 0.1)" + } +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index a3896fe..477bbb4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,8 @@ export default tseslint.config( ignores: [ 'dist', 'apps/desktop/dist', + 'apps/mobile/v1/**', + 'apps/mobile_old/**', 'node_modules', '.vite', 'coverage', diff --git a/scripts/update-mobile-version.js b/scripts/update-mobile-version.js new file mode 100644 index 0000000..ec674e4 --- /dev/null +++ b/scripts/update-mobile-version.js @@ -0,0 +1,117 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Get commits since last release +function getCommitsSinceLastRelease() { + try { + const commits = execSync('git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"%s"', { encoding: 'utf8' }); + return commits.split('\n').filter(Boolean); + } catch (error) { + // If no previous tag exists, get all commits + try { + const commits = execSync('git log --pretty=format:"%s"', { encoding: 'utf8' }); + return commits.split('\n').filter(Boolean); + } catch { + return []; + } + } +} + +// Check if there are mobile commits +function hasMobileCommits(commits) { + return commits.some(commit => commit.includes('(mobile)')); +} + +// Determine version bump type for mobile +function getMobileVersionBump(commits) { + const mobileCommits = commits.filter(commit => commit.includes('(mobile)')); + + // Check for breaking changes + if (mobileCommits.some(commit => commit.includes('BREAKING CHANGE') || commit.startsWith('feat(mobile)!'))) { + return 'major'; + } + + // Check for features + if (mobileCommits.some(commit => commit.startsWith('feat(mobile)'))) { + return 'minor'; + } + + // Check for fixes + if (mobileCommits.some(commit => commit.startsWith('fix(mobile)'))) { + return 'patch'; + } + + // For chore, style, refactor, etc. - still bump patch + if (mobileCommits.length > 0) { + return 'patch'; + } + + return null; +} + +// Bump version +function bumpVersion(version, type) { + const parts = version.split('.').map(Number); + + switch (type) { + case 'major': + return `${parts[0] + 1}.0.0`; + case 'minor': + return `${parts[0]}.${parts[1] + 1}.0`; + case 'patch': + return `${parts[0]}.${parts[1]}.${parts[2] + 1}`; + default: + return version; + } +} + +// Main execution +const commits = getCommitsSinceLastRelease(); + +if (!hasMobileCommits(commits)) { + console.log('No mobile commits found, skipping mobile version bump'); + process.exit(0); +} + +const bumpType = getMobileVersionBump(commits); +if (!bumpType) { + console.log('No mobile version bump needed'); + process.exit(0); +} + +// Update mobile app package.json +const mobilePackagePath = path.join(__dirname, '../apps/mobile/v1/package.json'); +const mobilePackage = JSON.parse(fs.readFileSync(mobilePackagePath, 'utf8')); +const oldVersion = mobilePackage.version; +const newVersion = bumpVersion(oldVersion, bumpType); +mobilePackage.version = newVersion; +fs.writeFileSync(mobilePackagePath, JSON.stringify(mobilePackage, null, 2) + '\n'); +console.log(`Updated mobile package.json from ${oldVersion} to ${newVersion} (${bumpType})`); + +// Update mobile app.json +const mobileAppJsonPath = path.join(__dirname, '../apps/mobile/v1/app.json'); +const mobileAppJson = JSON.parse(fs.readFileSync(mobileAppJsonPath, 'utf8')); +mobileAppJson.expo.version = newVersion; + +// Also bump build numbers for iOS and Android +if (mobileAppJson.expo.ios) { + const currentBuildNumber = parseInt(mobileAppJson.expo.ios.buildNumber || '1', 10); + mobileAppJson.expo.ios.buildNumber = String(currentBuildNumber + 1); + console.log(`Updated iOS buildNumber to ${mobileAppJson.expo.ios.buildNumber}`); +} + +if (mobileAppJson.expo.android) { + const currentVersionCode = parseInt(mobileAppJson.expo.android.versionCode || 1, 10); + mobileAppJson.expo.android.versionCode = currentVersionCode + 1; + console.log(`Updated Android versionCode to ${mobileAppJson.expo.android.versionCode}`); +} + +fs.writeFileSync(mobileAppJsonPath, JSON.stringify(mobileAppJson, null, 2) + '\n'); +console.log(`Updated mobile app.json to version ${newVersion}`); + +console.log(`\nā Mobile app version bumped: ${oldVersion} ā ${newVersion} (${bumpType})`);