ueberpruefen

This commit is contained in:
2025-10-28 23:38:37 +01:00
parent c2c5dd1041
commit 8dab9bfd25
56 changed files with 13640 additions and 2002 deletions

150
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,150 @@
import React from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
AccessibilityRole,
} from 'react-native';
import { colors, spacing, typography, borderRadius, MIN_TAP_SIZE } from '../lib/theme';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
accessibilityLabel?: string;
accessibilityHint?: string;
}
export const Button: React.FC<ButtonProps> = ({
title,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
style,
textStyle,
accessibilityLabel,
accessibilityHint,
}) => {
const isDisabled = disabled || loading;
const buttonStyle = [
styles.base,
styles[variant],
styles[`size_${size}`],
isDisabled && styles.disabled,
style,
];
const textStyles = [
styles.text,
styles[`text_${variant}`],
styles[`textSize_${size}`],
isDisabled && styles.textDisabled,
textStyle,
];
return (
<TouchableOpacity
style={buttonStyle}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel || title}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled: isDisabled }}
>
{loading ? (
<ActivityIndicator
color={variant === 'primary' ? colors.background : colors.primary}
size="small"
/>
) : (
<Text style={textStyles}>{title}</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.md,
minHeight: MIN_TAP_SIZE,
borderWidth: 2, // Retro thick borders
},
primary: {
backgroundColor: colors.primary,
borderColor: colors.primaryDark,
},
secondary: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.border,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: colors.primary,
},
ghost: {
backgroundColor: 'transparent',
borderWidth: 0,
},
disabled: {
opacity: 0.5,
},
size_sm: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minHeight: 36,
},
size_md: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
size_lg: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
},
text: {
fontWeight: typography.fontWeight.bold,
letterSpacing: 0.5, // Retro spacing
textTransform: 'uppercase', // Retro all-caps buttons
},
text_primary: {
color: colors.background,
},
text_secondary: {
color: colors.text,
},
text_outline: {
color: colors.primary,
},
text_ghost: {
color: colors.primary,
},
textDisabled: {
opacity: 0.7,
},
textSize_sm: {
fontSize: typography.fontSize.sm,
},
textSize_md: {
fontSize: typography.fontSize.md,
},
textSize_lg: {
fontSize: typography.fontSize.lg,
},
});

27
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,27 @@
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import { colors, spacing, borderRadius, shadows } from '../lib/theme';
interface CardProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
elevated?: boolean;
}
export const Card: React.FC<CardProps> = ({ children, style, elevated = true }) => {
return (
<View style={[styles.card, elevated && shadows.md, style]}>
{children}
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: borderRadius.lg,
padding: spacing.lg, // More padding for retro feel
borderWidth: 2, // Thicker border for vintage look
borderColor: colors.border,
},
});

77
src/components/Input.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React from 'react';
import {
TextInput,
View,
Text,
StyleSheet,
TextInputProps,
ViewStyle,
} from 'react-native';
import { colors, spacing, typography, borderRadius, MIN_TAP_SIZE } from '../lib/theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
containerStyle?: ViewStyle;
}
export const Input: React.FC<InputProps> = ({
label,
error,
containerStyle,
style,
...textInputProps
}) => {
return (
<View style={[styles.container, containerStyle]}>
{label && (
<Text style={styles.label} accessibilityLabel={label}>
{label}
</Text>
)}
<TextInput
style={[styles.input, error && styles.inputError, style]}
placeholderTextColor={colors.textTertiary}
accessibilityLabel={label || textInputProps.placeholder}
{...textInputProps}
/>
{error && (
<Text style={styles.error} accessibilityLiveRegion="polite">
{error}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.sm,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
input: {
backgroundColor: colors.background,
borderWidth: 2, // Thicker retro border
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: typography.fontSize.md,
color: colors.text,
minHeight: MIN_TAP_SIZE,
},
inputError: {
borderColor: colors.error,
},
error: {
fontSize: typography.fontSize.xs,
color: colors.error,
marginTop: spacing.xs,
},
});

3
src/components/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './Button';
export * from './Card';
export * from './Input';

View File

@@ -0,0 +1,173 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
hasCompletedOnboarding: boolean;
signUp: (email: string, password: string, name: string) => Promise<void>;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
completeOnboarding: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_STORAGE_KEY = '@pottery_diary_auth';
const USERS_STORAGE_KEY = '@pottery_diary_users';
const ONBOARDING_STORAGE_KEY = '@pottery_diary_onboarding';
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(false);
useEffect(() => {
loadUser();
}, []);
const loadUser = async () => {
try {
const userData = await AsyncStorage.getItem(AUTH_STORAGE_KEY);
if (userData) {
const parsedUser = JSON.parse(userData);
setUser(parsedUser);
// Load onboarding status for this user
const onboardingData = await AsyncStorage.getItem(ONBOARDING_STORAGE_KEY);
if (onboardingData) {
const completedUsers = JSON.parse(onboardingData);
setHasCompletedOnboarding(completedUsers[parsedUser.id] === true);
}
}
} catch (error) {
console.error('Failed to load user:', error);
} finally {
setLoading(false);
}
};
const signUp = async (email: string, password: string, name: string) => {
try {
// Get existing users
const usersData = await AsyncStorage.getItem(USERS_STORAGE_KEY);
const users: Record<string, { email: string; password: string; name: string; id: string }> =
usersData ? JSON.parse(usersData) : {};
// Check if user already exists
if (users[email.toLowerCase()]) {
throw new Error('User with this email already exists');
}
// Create new user
const newUser: User = {
id: Date.now().toString(),
email: email.toLowerCase(),
name,
};
// Store user credentials
users[email.toLowerCase()] = {
email: email.toLowerCase(),
password, // In production, this should be hashed
name,
id: newUser.id,
};
await AsyncStorage.setItem(USERS_STORAGE_KEY, JSON.stringify(users));
await AsyncStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(newUser));
setUser(newUser);
setHasCompletedOnboarding(false); // New users haven't completed onboarding
} catch (error) {
console.error('Sign up failed:', error);
throw error;
}
};
const signIn = async (email: string, password: string) => {
try {
// Get existing users
const usersData = await AsyncStorage.getItem(USERS_STORAGE_KEY);
const users: Record<string, { email: string; password: string; name: string; id: string }> =
usersData ? JSON.parse(usersData) : {};
const storedUser = users[email.toLowerCase()];
if (!storedUser || storedUser.password !== password) {
throw new Error('Invalid email or password');
}
const currentUser: User = {
id: storedUser.id,
email: storedUser.email,
name: storedUser.name,
};
await AsyncStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(currentUser));
setUser(currentUser);
// Load onboarding status for returning user
const onboardingData = await AsyncStorage.getItem(ONBOARDING_STORAGE_KEY);
if (onboardingData) {
const completedUsers = JSON.parse(onboardingData);
setHasCompletedOnboarding(completedUsers[currentUser.id] === true);
} else {
setHasCompletedOnboarding(false);
}
} catch (error) {
console.error('Sign in failed:', error);
throw error;
}
};
const signOut = async () => {
try {
await AsyncStorage.removeItem(AUTH_STORAGE_KEY);
setUser(null);
setHasCompletedOnboarding(false);
} catch (error) {
console.error('Sign out failed:', error);
throw error;
}
};
const completeOnboarding = async () => {
if (!user) return;
try {
// Get existing onboarding data
const onboardingData = await AsyncStorage.getItem(ONBOARDING_STORAGE_KEY);
const completedUsers = onboardingData ? JSON.parse(onboardingData) : {};
// Mark this user as having completed onboarding
completedUsers[user.id] = true;
await AsyncStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(completedUsers));
setHasCompletedOnboarding(true);
} catch (error) {
console.error('Failed to save onboarding completion:', error);
throw error;
}
};
return (
<AuthContext.Provider value={{ user, loading, hasCompletedOnboarding, signUp, signIn, signOut, completeOnboarding }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,72 @@
import { getSettings } from '../db/repositories/settingsRepository';
export interface AnalyticsEvent {
name: string;
properties?: Record<string, any>;
}
/**
* Analytics abstraction with privacy controls
* By default, all events are no-op unless user opts in
*/
class Analytics {
private optedIn: boolean = false;
async initialize(userId?: string): Promise<void> {
// Analytics opt-in will be loaded once user is logged in
// For now, default to false
this.optedIn = false;
}
setOptIn(optIn: boolean): void {
this.optedIn = optIn;
}
track(eventName: string, properties?: Record<string, any>): void {
if (!this.optedIn) return;
// In a real app, this would send to analytics service
// For now, just log in dev mode
if (__DEV__) {
console.log('[Analytics]', eventName, properties);
}
// TODO: Integrate with Sentry, Amplitude, or similar
// Example: Sentry.addBreadcrumb({ category: 'analytics', message: eventName, data: properties });
}
// Convenience methods for common events
appOpen(cold: boolean): void {
this.track('app_open', { cold });
}
projectCreated(withCover: boolean): void {
this.track('project_created', { with_cover: withCover });
}
stepAdded(type: string, hasPhotos: boolean): void {
this.track('step_added', { type, has_photos: hasPhotos });
}
firingLogged(cone: string, tempUnit: string): void {
this.track('firing_logged', { cone, temp_unit: tempUnit });
}
glazeAddedToStep(brand: string, isCustom: boolean, coats: number): void {
this.track('glaze_added_to_step', { brand, is_custom: isCustom, coats });
}
settingsChanged(unitSystem: string, tempUnit: string): void {
this.track('settings_changed', { unitSystem, tempUnit });
}
exportDone(format: string): void {
this.track('export_done', { format });
}
contactSubmitted(success: boolean): void {
this.track('contact_submitted', { success });
}
}
export const analytics = new Analytics();

194
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,194 @@
import * as SQLite from 'expo-sqlite';
import { CREATE_TABLES, SCHEMA_VERSION } from './schema';
let db: SQLite.SQLiteDatabase | null = null;
/**
* Open or create the database
*/
export async function openDatabase(): Promise<SQLite.SQLiteDatabase> {
if (db) {
return db;
}
db = await SQLite.openDatabaseAsync('pottery_diary.db');
// Enable foreign keys
await db.execAsync('PRAGMA foreign_keys = ON;');
// Check schema version
try {
const result = await db.getFirstAsync<{ user_version: number }>(
'PRAGMA user_version;'
);
const currentVersion = result?.user_version || 0;
if (currentVersion < SCHEMA_VERSION) {
await migrateDatabase(db, currentVersion);
}
} catch (error) {
console.error('Error checking schema version:', error);
throw error;
}
return db;
}
/**
* Run database migrations
*/
async function migrateDatabase(
database: SQLite.SQLiteDatabase,
fromVersion: number
): Promise<void> {
console.log(`Migrating database from version ${fromVersion} to ${SCHEMA_VERSION}`);
if (fromVersion === 0) {
// Initial schema creation
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION};`);
return;
}
// Migration from v1 to v2: Add color column to glazes
if (fromVersion < 2) {
console.log('Migrating to version 2: Dropping and recreating tables with color column');
// Drop all tables and recreate with new schema
await database.execAsync(`
DROP TABLE IF EXISTS step_glazes;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
`);
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = 2;`);
console.log('Migration to version 2 complete');
}
// Migration from v2 to v3: Re-seed glazes with 125 glazes
if (fromVersion < 3) {
console.log('Migrating to version 3: Clearing and re-seeding glaze catalog');
// Just delete all glazes and they will be re-seeded on next app init
await database.execAsync(`DELETE FROM glazes;`);
await database.execAsync(`PRAGMA user_version = 3;`);
console.log('Migration to version 3 complete');
}
// Migration from v3 to v4: Add mixed glaze support
if (fromVersion < 4) {
console.log('Migrating to version 4: Adding mixed glaze columns');
await database.execAsync(`
ALTER TABLE glazes ADD COLUMN is_mix INTEGER NOT NULL DEFAULT 0;
ALTER TABLE glazes ADD COLUMN mixed_glaze_ids TEXT;
ALTER TABLE glazes ADD COLUMN mix_ratio TEXT;
`);
await database.execAsync(`PRAGMA user_version = 4;`);
console.log('Migration to version 4 complete');
}
// Migration from v4 to v5: Add mix_notes to glazing_fields
if (fromVersion < 5) {
console.log('Migrating to version 5: Adding mix_notes to glazing_fields');
await database.execAsync(`
ALTER TABLE glazing_fields ADD COLUMN mix_notes TEXT;
`);
await database.execAsync(`PRAGMA user_version = 5;`);
console.log('Migration to version 5 complete');
}
// Migration from v5 to v6: Fix steps table to include 'trimming' type
if (fromVersion < 6) {
console.log('Migrating to version 6: Recreating steps table with trimming type');
// SQLite doesn't support modifying CHECK constraints, so we need to recreate the table
await database.execAsync(`
-- Create new table with correct constraint
CREATE TABLE steps_new (
id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('forming', 'trimming', 'drying', 'bisque_firing', 'glazing', 'glaze_firing', 'misc')),
notes_markdown TEXT,
photo_uris TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Copy data from old table
INSERT INTO steps_new SELECT * FROM steps;
-- Drop old table
DROP TABLE steps;
-- Rename new table
ALTER TABLE steps_new RENAME TO steps;
-- Recreate indexes
CREATE INDEX IF NOT EXISTS idx_steps_project_id ON steps(project_id);
CREATE INDEX IF NOT EXISTS idx_steps_type ON steps(type);
`);
await database.execAsync(`PRAGMA user_version = 6;`);
console.log('Migration to version 6 complete - trimming type now supported');
}
// Migration from v6 to v7: Add user_id columns for multi-user support
if (fromVersion < 7) {
console.log('Migrating to version 7: Adding user_id columns for multi-user support');
// For simplicity during development, we'll drop and recreate all tables
// In production, you'd want to preserve data and migrate it properly
await database.execAsync(`
-- Drop all tables
DROP TABLE IF EXISTS firing_fields;
DROP TABLE IF EXISTS glazing_fields;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS news_items;
`);
// Recreate with new schema
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = 7;`);
console.log('Migration to version 7 complete - multi-user support added');
console.log('Note: All existing data was cleared. Glazes will be re-seeded.');
}
}
/**
* Close the database
*/
export async function closeDatabase(): Promise<void> {
if (db) {
await db.closeAsync();
db = null;
}
}
/**
* Get the current database instance
*/
export function getDatabase(): SQLite.SQLiteDatabase {
if (!db) {
throw new Error('Database not initialized. Call openDatabase() first.');
}
return db;
}
/**
* Execute a transaction
*/
export async function runTransaction(
callback: (tx: SQLite.SQLiteDatabase) => Promise<void>
): Promise<void> {
const database = getDatabase();
try {
await database.execAsync('BEGIN TRANSACTION;');
await callback(database);
await database.execAsync('COMMIT;');
} catch (error) {
await database.execAsync('ROLLBACK;');
throw error;
}
}

View File

@@ -0,0 +1,250 @@
import { getDatabase } from '../index';
import { Glaze } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
import seedGlazes from '../../../../assets/seed/glazes.json';
export async function seedGlazeCatalog(): Promise<void> {
const db = getDatabase();
// Check if glazes are already seeded (seed glazes have no user_id)
const count = await db.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM glazes WHERE is_custom = 0 AND user_id IS NULL'
);
if (count && count.count > 0) {
return; // Already seeded
}
// Insert seed glazes (with NULL user_id to indicate they're shared)
for (const seedGlaze of seedGlazes) {
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, color, finish, notes, is_custom, created_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 0, ?)`,
[
generateUUID(),
seedGlaze.brand,
seedGlaze.name,
seedGlaze.code || null,
(seedGlaze as any).color || null,
seedGlaze.finish || 'unknown',
seedGlaze.notes || null,
now(),
]
);
}
}
export async function createCustomGlaze(
userId: string,
brand: string,
name: string,
code?: string,
finish?: Glaze['finish'],
notes?: string
): Promise<Glaze> {
const db = getDatabase();
const glaze: Glaze = {
id: generateUUID(),
brand,
name,
code,
finish,
notes,
isCustom: true,
};
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, finish, notes, is_custom, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[
glaze.id,
userId,
glaze.brand,
glaze.name,
glaze.code || null,
glaze.finish || 'unknown',
glaze.notes || null,
now(),
]
);
return glaze;
}
export async function createMixedGlaze(
userId: string,
glazes: Glaze[],
mixedColor: string,
mixRatio?: string
): Promise<Glaze> {
const db = getDatabase();
// Generate a name for the mix
const mixName = glazes.map(g => g.name).join(' + ');
const mixBrand = 'Mixed';
const glaze: Glaze = {
id: generateUUID(),
brand: mixBrand,
name: mixName,
color: mixedColor,
finish: 'unknown',
notes: mixRatio || undefined,
isCustom: true,
isMix: true,
mixedGlazeIds: glazes.map(g => g.id),
mixRatio: mixRatio || undefined,
};
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, color, finish, notes, is_custom, is_mix, mixed_glaze_ids, mix_ratio, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?)`,
[
glaze.id,
userId,
glaze.brand,
glaze.name,
null, // no code for mixed glazes
glaze.color || null,
glaze.finish || null,
glaze.notes || null,
JSON.stringify(glaze.mixedGlazeIds),
glaze.mixRatio || null,
now(),
]
);
return glaze;
}
export async function getGlaze(id: string): Promise<Glaze | null> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM glazes WHERE id = ?',
[id]
);
if (!row) return null;
return {
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
};
}
export async function getAllGlazes(userId?: string): Promise<Glaze[]> {
const db = getDatabase();
// Get seed glazes (user_id IS NULL) and user's custom glazes
const rows = await db.getAllAsync<any>(
userId
? 'SELECT * FROM glazes WHERE user_id IS NULL OR user_id = ? ORDER BY brand, name'
: 'SELECT * FROM glazes WHERE user_id IS NULL ORDER BY brand, name',
userId ? [userId] : []
);
return rows.map(row => ({
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
}));
}
export async function searchGlazes(query: string, userId?: string): Promise<Glaze[]> {
const db = getDatabase();
const searchTerm = `%${query}%`;
const rows = await db.getAllAsync<any>(
userId
? `SELECT * FROM glazes
WHERE (user_id IS NULL OR user_id = ?)
AND (brand LIKE ? OR name LIKE ? OR code LIKE ?)
ORDER BY brand, name`
: `SELECT * FROM glazes
WHERE user_id IS NULL
AND (brand LIKE ? OR name LIKE ? OR code LIKE ?)
ORDER BY brand, name`,
userId
? [userId, searchTerm, searchTerm, searchTerm]
: [searchTerm, searchTerm, searchTerm]
);
return rows.map(row => ({
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
}));
}
export async function updateGlaze(
id: string,
updates: Partial<Omit<Glaze, 'id' | 'isCustom'>>
): Promise<void> {
const db = getDatabase();
const glaze = await getGlaze(id);
if (!glaze) throw new Error('Glaze not found');
if (!glaze.isCustom) throw new Error('Cannot update seed glazes');
const updated = { ...glaze, ...updates };
await db.runAsync(
`UPDATE glazes
SET brand = ?, name = ?, code = ?, finish = ?, notes = ?
WHERE id = ?`,
[
updated.brand,
updated.name,
updated.code || null,
updated.finish || 'unknown',
updated.notes || null,
id,
]
);
}
export async function deleteGlaze(id: string): Promise<void> {
const db = getDatabase();
const glaze = await getGlaze(id);
if (!glaze) return;
if (!glaze.isCustom) throw new Error('Cannot delete seed glazes');
await db.runAsync('DELETE FROM glazes WHERE id = ?', [id]);
}
export async function getGlazesByIds(ids: string[]): Promise<Glaze[]> {
if (ids.length === 0) return [];
const glazes: Glaze[] = [];
for (const id of ids) {
const glaze = await getGlaze(id);
if (glaze) glazes.push(glaze);
}
return glazes;
}

View File

@@ -0,0 +1,5 @@
export * from './projectRepository';
export * from './stepRepository';
export * from './glazeRepository';
export * from './settingsRepository';
export * from './newsRepository';

View File

@@ -0,0 +1,54 @@
import { getDatabase } from '../index';
import { NewsItem } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
export async function cacheNewsItems(items: NewsItem[]): Promise<void> {
const db = getDatabase();
const timestamp = now();
for (const item of items) {
// Upsert news item
await db.runAsync(
`INSERT OR REPLACE INTO news_items (id, title, excerpt, url, content_html, published_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
item.id,
item.title,
item.excerpt || null,
item.url || null,
item.contentHtml || null,
item.publishedAt,
timestamp,
]
);
}
}
export async function getCachedNewsItems(limit: number = 20): Promise<NewsItem[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM news_items ORDER BY published_at DESC LIMIT ?',
[limit]
);
return rows.map(row => ({
id: row.id,
title: row.title,
excerpt: row.excerpt || undefined,
url: row.url || undefined,
contentHtml: row.content_html || undefined,
publishedAt: row.published_at,
}));
}
export async function clearOldNewsCache(daysToKeep: number = 30): Promise<void> {
const db = getDatabase();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
await db.runAsync(
'DELETE FROM news_items WHERE cached_at < ?',
[cutoffDate.toISOString()]
);
}

View File

@@ -0,0 +1,130 @@
import { getDatabase } from '../index';
import { Project } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
export async function createProject(
userId: string,
title: string,
tags: string[] = [],
status: Project['status'] = 'in_progress',
coverImageUri?: string
): Promise<Project> {
const db = getDatabase();
const project: Project = {
id: generateUUID(),
title,
status,
tags,
coverImageUri,
createdAt: now(),
updatedAt: now(),
};
await db.runAsync(
`INSERT INTO projects (id, user_id, title, status, tags, cover_image_uri, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
project.id,
userId,
project.title,
project.status,
JSON.stringify(project.tags),
project.coverImageUri || null,
project.createdAt,
project.updatedAt,
]
);
return project;
}
export async function getProject(id: string): Promise<Project | null> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM projects WHERE id = ?',
[id]
);
if (!row) return null;
return {
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getAllProjects(userId: string): Promise<Project[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM projects WHERE user_id = ? ORDER BY updated_at DESC',
[userId]
);
return rows.map(row => ({
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function updateProject(
id: string,
updates: Partial<Omit<Project, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<void> {
const db = getDatabase();
const project = await getProject(id);
if (!project) throw new Error('Project not found');
const updatedProject = {
...project,
...updates,
updatedAt: now(),
};
await db.runAsync(
`UPDATE projects
SET title = ?, status = ?, tags = ?, cover_image_uri = ?, updated_at = ?
WHERE id = ?`,
[
updatedProject.title,
updatedProject.status,
JSON.stringify(updatedProject.tags),
updatedProject.coverImageUri || null,
updatedProject.updatedAt,
id,
]
);
}
export async function deleteProject(id: string): Promise<void> {
const db = getDatabase();
await db.runAsync('DELETE FROM projects WHERE id = ?', [id]);
}
export async function getProjectsByStatus(userId: string, status: Project['status']): Promise<Project[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM projects WHERE user_id = ? AND status = ? ORDER BY updated_at DESC',
[userId, status]
);
return rows.map(row => ({
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}

View File

@@ -0,0 +1,51 @@
import { getDatabase } from '../index';
import { Settings } from '../../../types';
export async function getSettings(userId: string): Promise<Settings> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM settings WHERE user_id = ?',
[userId]
);
if (!row) {
// Create default settings for this user
const defaults: Settings = {
unitSystem: 'imperial',
tempUnit: 'F',
analyticsOptIn: false,
};
await db.runAsync(
`INSERT INTO settings (user_id, unit_system, temp_unit, analytics_opt_in)
VALUES (?, ?, ?, ?)`,
[userId, defaults.unitSystem, defaults.tempUnit, defaults.analyticsOptIn ? 1 : 0]
);
return defaults;
}
return {
unitSystem: row.unit_system,
tempUnit: row.temp_unit,
analyticsOptIn: row.analytics_opt_in === 1,
};
}
export async function updateSettings(userId: string, updates: Partial<Settings>): Promise<void> {
const db = getDatabase();
const current = await getSettings(userId);
const updated = { ...current, ...updates };
await db.runAsync(
`UPDATE settings
SET unit_system = ?, temp_unit = ?, analytics_opt_in = ?
WHERE user_id = ?`,
[
updated.unitSystem,
updated.tempUnit,
updated.analyticsOptIn ? 1 : 0,
userId,
]
);
}

View File

@@ -0,0 +1,229 @@
import { getDatabase } from '../index';
import { Step, StepType, FiringFields, GlazingFields } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
interface CreateStepParams {
projectId: string;
type: StepType;
notesMarkdown?: string;
photoUris?: string[];
firing?: FiringFields;
glazing?: GlazingFields;
}
export async function createStep(params: CreateStepParams): Promise<Step> {
const db = getDatabase();
const stepId = generateUUID();
const timestamp = now();
// Insert base step
await db.runAsync(
`INSERT INTO steps (id, project_id, type, notes_markdown, photo_uris, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
stepId,
params.projectId,
params.type,
params.notesMarkdown || null,
JSON.stringify(params.photoUris || []),
timestamp,
timestamp,
]
);
// Insert type-specific fields
if (params.type === 'bisque_firing' || params.type === 'glaze_firing') {
if (params.firing) {
await db.runAsync(
`INSERT INTO firing_fields (step_id, cone, temperature_value, temperature_unit, duration_minutes, kiln_notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
stepId,
params.firing.cone || null,
params.firing.temperature?.value || null,
params.firing.temperature?.unit || null,
params.firing.durationMinutes || null,
params.firing.kilnNotes || null,
]
);
}
} else if (params.type === 'glazing') {
if (params.glazing) {
await db.runAsync(
`INSERT INTO glazing_fields (step_id, glaze_ids, coats, application, mix_notes)
VALUES (?, ?, ?, ?, ?)`,
[
stepId,
JSON.stringify(params.glazing.glazeIds),
params.glazing.coats || null,
params.glazing.application || null,
params.glazing.mixNotes || null,
]
);
}
}
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[timestamp, params.projectId]
);
const step = await getStep(stepId);
if (!step) throw new Error('Failed to create step');
return step;
}
export async function getStep(id: string): Promise<Step | null> {
const db = getDatabase();
const stepRow = await db.getFirstAsync<any>(
'SELECT * FROM steps WHERE id = ?',
[id]
);
if (!stepRow) return null;
const baseStep = {
id: stepRow.id,
projectId: stepRow.project_id,
type: stepRow.type as StepType,
notesMarkdown: stepRow.notes_markdown || undefined,
photoUris: JSON.parse(stepRow.photo_uris),
createdAt: stepRow.created_at,
updatedAt: stepRow.updated_at,
};
// Fetch type-specific fields
if (stepRow.type === 'bisque_firing' || stepRow.type === 'glaze_firing') {
const firingRow = await db.getFirstAsync<any>(
'SELECT * FROM firing_fields WHERE step_id = ?',
[id]
);
const firing: FiringFields = {
cone: firingRow?.cone || undefined,
temperature: firingRow?.temperature_value
? { value: firingRow.temperature_value, unit: firingRow.temperature_unit }
: undefined,
durationMinutes: firingRow?.duration_minutes || undefined,
kilnNotes: firingRow?.kiln_notes || undefined,
};
return { ...baseStep, type: stepRow.type, firing } as Step;
} else if (stepRow.type === 'glazing') {
const glazingRow = await db.getFirstAsync<any>(
'SELECT * FROM glazing_fields WHERE step_id = ?',
[id]
);
const glazing: GlazingFields = {
glazeIds: glazingRow ? JSON.parse(glazingRow.glaze_ids) : [],
coats: glazingRow?.coats || undefined,
application: glazingRow?.application || undefined,
mixNotes: glazingRow?.mix_notes || undefined,
};
return { ...baseStep, type: 'glazing', glazing } as Step;
}
return baseStep as Step;
}
export async function getStepsByProject(projectId: string): Promise<Step[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT id FROM steps WHERE project_id = ? ORDER BY created_at ASC',
[projectId]
);
const steps: Step[] = [];
for (const row of rows) {
const step = await getStep(row.id);
if (step) steps.push(step);
}
return steps;
}
export async function updateStep(id: string, updates: Partial<CreateStepParams>): Promise<void> {
const db = getDatabase();
const step = await getStep(id);
if (!step) throw new Error('Step not found');
const timestamp = now();
// Update base fields
if (updates.notesMarkdown !== undefined || updates.photoUris !== undefined) {
await db.runAsync(
`UPDATE steps
SET notes_markdown = ?, photo_uris = ?, updated_at = ?
WHERE id = ?`,
[
updates.notesMarkdown !== undefined ? updates.notesMarkdown : step.notesMarkdown || null,
updates.photoUris !== undefined ? JSON.stringify(updates.photoUris) : JSON.stringify(step.photoUris),
timestamp,
id,
]
);
}
// Update type-specific fields
if (step.type === 'bisque_firing' || step.type === 'glaze_firing') {
if (updates.firing) {
const existingFiring = 'firing' in step ? step.firing : {};
const mergedFiring = { ...existingFiring, ...updates.firing };
await db.runAsync(
`UPDATE firing_fields
SET cone = ?, temperature_value = ?, temperature_unit = ?, duration_minutes = ?, kiln_notes = ?
WHERE step_id = ?`,
[
mergedFiring.cone || null,
mergedFiring.temperature?.value || null,
mergedFiring.temperature?.unit || null,
mergedFiring.durationMinutes || null,
mergedFiring.kilnNotes || null,
id,
]
);
}
} else if (step.type === 'glazing' && updates.glazing) {
const existingGlazing = 'glazing' in step ? step.glazing : { glazeIds: [] };
const mergedGlazing = { ...existingGlazing, ...updates.glazing };
await db.runAsync(
`UPDATE glazing_fields
SET glaze_ids = ?, coats = ?, application = ?, mix_notes = ?
WHERE step_id = ?`,
[
JSON.stringify(mergedGlazing.glazeIds),
mergedGlazing.coats || null,
mergedGlazing.application || null,
mergedGlazing.mixNotes || null,
id,
]
);
}
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[timestamp, step.projectId]
);
}
export async function deleteStep(id: string): Promise<void> {
const db = getDatabase();
const step = await getStep(id);
if (!step) return;
await db.runAsync('DELETE FROM steps WHERE id = ?', [id]);
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[now(), step.projectId]
);
}

111
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Database schema definitions and migration scripts
*/
export const SCHEMA_VERSION = 7;
export const CREATE_TABLES = `
-- Projects table
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'done', 'archived')),
tags TEXT NOT NULL, -- JSON array
cover_image_uri TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Steps table
CREATE TABLE IF NOT EXISTS steps (
id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('forming', 'trimming', 'drying', 'bisque_firing', 'glazing', 'glaze_firing', 'misc')),
notes_markdown TEXT,
photo_uris TEXT NOT NULL, -- JSON array
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Firing fields (for bisque_firing and glaze_firing steps)
CREATE TABLE IF NOT EXISTS firing_fields (
step_id TEXT PRIMARY KEY NOT NULL,
cone TEXT,
temperature_value INTEGER,
temperature_unit TEXT CHECK(temperature_unit IN ('F', 'C')),
duration_minutes INTEGER,
kiln_notes TEXT,
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
-- Glazing fields (for glazing steps)
CREATE TABLE IF NOT EXISTS glazing_fields (
step_id TEXT PRIMARY KEY NOT NULL,
glaze_ids TEXT NOT NULL, -- JSON array
coats INTEGER,
application TEXT CHECK(application IN ('brush', 'dip', 'spray', 'pour', 'other')),
mix_notes TEXT, -- notes about glaze mixing ratios (e.g., "50/50", "3:1")
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
-- Glazes catalog
CREATE TABLE IF NOT EXISTS glazes (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT, -- NULL for seed glazes, set for custom glazes
brand TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT,
color TEXT, -- hex color code for preview
finish TEXT CHECK(finish IN ('glossy', 'satin', 'matte', 'special', 'unknown')),
notes TEXT,
is_custom INTEGER NOT NULL DEFAULT 0, -- 0 = seed, 1 = user custom
is_mix INTEGER NOT NULL DEFAULT 0, -- 0 = regular glaze, 1 = mixed glaze
mixed_glaze_ids TEXT, -- JSON array of glaze IDs that were mixed
mix_ratio TEXT, -- user's notes about the mix ratio (e.g., "50/50", "3:1")
created_at TEXT NOT NULL
);
-- Settings (per user)
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT PRIMARY KEY NOT NULL,
unit_system TEXT NOT NULL CHECK(unit_system IN ('imperial', 'metric')),
temp_unit TEXT NOT NULL CHECK(temp_unit IN ('F', 'C')),
analytics_opt_in INTEGER NOT NULL DEFAULT 0
);
-- News/Tips cache (per user)
CREATE TABLE IF NOT EXISTS news_items (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
url TEXT,
content_html TEXT,
published_at TEXT NOT NULL,
cached_at TEXT NOT NULL
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_steps_project_id ON steps(project_id);
CREATE INDEX IF NOT EXISTS idx_steps_type ON steps(type);
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_glazes_user_id ON glazes(user_id);
CREATE INDEX IF NOT EXISTS idx_glazes_brand ON glazes(brand);
CREATE INDEX IF NOT EXISTS idx_glazes_is_custom ON glazes(is_custom);
CREATE INDEX IF NOT EXISTS idx_news_user_id ON news_items(user_id);
CREATE INDEX IF NOT EXISTS idx_news_published_at ON news_items(published_at DESC);
`;
export const DROP_ALL_TABLES = `
DROP TABLE IF EXISTS firing_fields;
DROP TABLE IF EXISTS glazing_fields;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS news_items;
`;

138
src/lib/theme/index.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Theme configuration for Pottery Diary
* Retro, vintage-inspired design with warm earth tones
*/
export const colors = {
// Primary colors - warm vintage terracotta
primary: '#C17855', // Warm terracotta/clay
primaryLight: '#D4956F',
primaryDark: '#A5643E',
// Backgrounds - vintage paper tones
background: '#F5EDE4', // Warm cream/aged paper
backgroundSecondary: '#EBE0D5', // Darker vintage beige
card: '#FAF6F1', // Soft off-white card
// Text - warm vintage ink colors
text: '#3E2A1F', // Dark brown instead of black
textSecondary: '#8B7355', // Warm mid-brown
textTertiary: '#B59A7F', // Light warm brown
// Borders - subtle warm tones
border: '#D4C4B0',
borderLight: '#E5D9C9',
// Status colors - muted retro palette
success: '#6B8E4E', // Muted olive green
warning: '#D4894F', // Warm amber
error: '#B85C50', // Muted terracotta red
info: '#5B8A9F', // Muted teal
// Step type colors (icons) - vintage pottery palette
forming: '#8B5A3C', // Rich clay brown
trimming: '#A67C52', // Warm tan/tool color
drying: '#D4A05B', // Warm sand
bisqueFiring: '#C75B3F', // Burnt orange
glazing: '#7B5E7B', // Muted purple/mauve
glazeFiring: '#D86F4D', // Warm coral
misc: '#6B7B7B', // Vintage grey-blue
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const typography = {
fontSize: {
xs: 12,
sm: 14,
md: 16,
lg: 20, // Slightly larger for retro feel
xl: 28, // Bigger, bolder headers
xxl: 36,
},
fontWeight: {
regular: '400' as const,
medium: '500' as const,
semiBold: '600' as const,
bold: '800' as const, // Bolder for retro headings
},
// Retro-style letter spacing
letterSpacing: {
tight: -0.5,
normal: 0,
wide: 0.5,
wider: 1,
},
};
export const borderRadius = {
sm: 6,
md: 12, // More rounded for retro feel
lg: 16,
xl: 24,
full: 9999,
};
export const shadows = {
sm: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 2,
},
md: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 4,
},
lg: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 5 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 6,
},
};
// Minimum tappable size for accessibility
export const MIN_TAP_SIZE = 44;
export const stepTypeColors = {
forming: colors.forming,
trimming: colors.trimming,
drying: colors.drying,
bisque_firing: colors.bisqueFiring,
glazing: colors.glazing,
glaze_firing: colors.glazeFiring,
misc: colors.misc,
};
export const stepTypeIcons: Record<string, string> = {
forming: '🏺',
trimming: '🔧',
drying: '☀️',
bisque_firing: '🔥',
glazing: '🎨',
glaze_firing: '⚡',
misc: '📝',
};
export const stepTypeLabels: Record<string, string> = {
forming: 'Forming',
trimming: 'Trimming',
drying: 'Drying',
bisque_firing: 'Bisque Firing',
glazing: 'Glazing',
glaze_firing: 'Glaze Firing',
misc: 'Misc',
};

View File

@@ -0,0 +1,108 @@
/**
* Utility functions for mixing glaze colors
*/
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Convert RGB to hex color
*/
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Mix multiple hex colors together (average RGB values)
* @param colors Array of hex color strings (e.g. ['#FF0000', '#0000FF'])
* @returns Mixed hex color string
*/
export function mixColors(colors: string[]): string {
if (colors.length === 0) {
return '#808080'; // Default gray
}
if (colors.length === 1) {
return colors[0];
}
// Convert all colors to RGB
const rgbColors = colors.map(hexToRgb).filter((rgb) => rgb !== null) as {
r: number;
g: number;
b: number;
}[];
if (rgbColors.length === 0) {
return '#808080'; // Default gray
}
// Calculate average RGB values
const avgR = rgbColors.reduce((sum, rgb) => sum + rgb.r, 0) / rgbColors.length;
const avgG = rgbColors.reduce((sum, rgb) => sum + rgb.g, 0) / rgbColors.length;
const avgB = rgbColors.reduce((sum, rgb) => sum + rgb.b, 0) / rgbColors.length;
// Convert back to hex
return rgbToHex(avgR, avgG, avgB);
}
/**
* Mix two colors with custom ratios
* @param color1 First hex color
* @param color2 Second hex color
* @param ratio1 Weight of first color (0-1), ratio2 will be (1 - ratio1)
* @returns Mixed hex color string
*/
export function mixColorsWithRatio(color1: string, color2: string, ratio1: number): string {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) {
return '#808080';
}
const ratio2 = 1 - ratio1;
const r = rgb1.r * ratio1 + rgb2.r * ratio2;
const g = rgb1.g * ratio1 + rgb2.g * ratio2;
const b = rgb1.b * ratio1 + rgb2.b * ratio2;
return rgbToHex(r, g, b);
}
/**
* Generate a name for a mixed glaze
* @param glazeNames Array of glaze names that were mixed
* @returns Generated name like "Blue + Yellow Mix"
*/
export function generateMixName(glazeNames: string[]): string {
if (glazeNames.length === 0) {
return 'Custom Mix';
}
if (glazeNames.length === 1) {
return glazeNames[0];
}
if (glazeNames.length === 2) {
return `${glazeNames[0]} + ${glazeNames[1]} Mix`;
}
// For 3+ glazes, show first two and count
return `${glazeNames[0]} + ${glazeNames[1]} + ${glazeNames.length - 2} more`;
}

View File

@@ -0,0 +1,137 @@
import { Temperature } from '../../types';
/**
* Orton Cone temperature reference data (self-supporting, end point temperatures)
* Based on standard firing rates for ceramics in Fahrenheit
*/
export interface ConeData {
cone: string;
fahrenheit: number;
celsius: number;
description: string;
}
export const CONE_TEMPERATURE_CHART: ConeData[] = [
{ cone: '022', fahrenheit: 1087, celsius: 586, description: 'Very low fire - overglaze' },
{ cone: '021', fahrenheit: 1112, celsius: 600, description: 'Very low fire' },
{ cone: '020', fahrenheit: 1159, celsius: 626, description: 'Low fire' },
{ cone: '019', fahrenheit: 1213, celsius: 656, description: 'Low fire' },
{ cone: '018', fahrenheit: 1267, celsius: 686, description: 'Low fire' },
{ cone: '017', fahrenheit: 1301, celsius: 705, description: 'Low fire' },
{ cone: '016', fahrenheit: 1368, celsius: 742, description: 'Low fire' },
{ cone: '015', fahrenheit: 1436, celsius: 780, description: 'Low fire' },
{ cone: '014', fahrenheit: 1485, celsius: 807, description: 'Low fire' },
{ cone: '013', fahrenheit: 1539, celsius: 837, description: 'Low fire' },
{ cone: '012', fahrenheit: 1582, celsius: 861, description: 'Low fire' },
{ cone: '011', fahrenheit: 1641, celsius: 894, description: 'Low fire' },
{ cone: '010', fahrenheit: 1657, celsius: 903, description: 'Low fire' },
{ cone: '09', fahrenheit: 1688, celsius: 920, description: 'Low fire' },
{ cone: '08', fahrenheit: 1728, celsius: 942, description: 'Low fire' },
{ cone: '07', fahrenheit: 1789, celsius: 976, description: 'Low fire' },
{ cone: '06', fahrenheit: 1828, celsius: 998, description: 'Earthenware / Low fire glaze' },
{ cone: '05', fahrenheit: 1888, celsius: 1031, description: 'Earthenware / Low fire glaze' },
{ cone: '04', fahrenheit: 1945, celsius: 1063, description: 'Bisque firing / Low fire glaze' },
{ cone: '03', fahrenheit: 1987, celsius: 1086, description: 'Bisque firing' },
{ cone: '02', fahrenheit: 2016, celsius: 1102, description: 'Mid-range' },
{ cone: '01', fahrenheit: 2046, celsius: 1119, description: 'Mid-range' },
{ cone: '1', fahrenheit: 2079, celsius: 1137, description: 'Mid-range' },
{ cone: '2', fahrenheit: 2088, celsius: 1142, description: 'Mid-range' },
{ cone: '3', fahrenheit: 2106, celsius: 1152, description: 'Mid-range' },
{ cone: '4', fahrenheit: 2124, celsius: 1162, description: 'Mid-range' },
{ cone: '5', fahrenheit: 2167, celsius: 1186, description: 'Mid-range / Stoneware' },
{ cone: '6', fahrenheit: 2232, celsius: 1222, description: 'Stoneware / Mid-range glaze' },
{ cone: '7', fahrenheit: 2262, celsius: 1239, description: 'Stoneware' },
{ cone: '8', fahrenheit: 2280, celsius: 1249, description: 'Stoneware' },
{ cone: '9', fahrenheit: 2300, celsius: 1260, description: 'High fire' },
{ cone: '10', fahrenheit: 2345, celsius: 1285, description: 'High fire / Stoneware' },
{ cone: '11', fahrenheit: 2361, celsius: 1294, description: 'High fire' },
{ cone: '12', fahrenheit: 2383, celsius: 1306, description: 'High fire' },
{ cone: '13', fahrenheit: 2410, celsius: 1321, description: 'Very high fire' },
{ cone: '14', fahrenheit: 2431, celsius: 1332, description: 'Very high fire / Porcelain' },
];
/**
* Get temperature for a specific Orton cone
* @param cone - The cone number (e.g., "04", "6", "10")
* @param preferredUnit - The user's preferred temperature unit ('F' or 'C')
*/
export function getConeTemperature(cone: string, preferredUnit: 'F' | 'C' = 'F'): Temperature | null {
const normalized = cone.trim().toLowerCase().replace(/^0+(?=[1-9])/, '0');
const coneData = CONE_TEMPERATURE_CHART.find(
c => c.cone.toLowerCase() === normalized
);
if (!coneData) {
return null;
}
return {
value: preferredUnit === 'C' ? coneData.celsius : coneData.fahrenheit,
unit: preferredUnit,
};
}
/**
* Get cone data by cone number
*/
export function getConeData(cone: string): ConeData | null {
const normalized = cone.trim().toLowerCase().replace(/^0+(?=[1-9])/, '0');
return CONE_TEMPERATURE_CHART.find(
c => c.cone.toLowerCase() === normalized
) || null;
}
/**
* Suggest cone based on temperature (returns closest match)
*/
export function suggestConeFromTemperature(temp: Temperature): ConeData | null {
const fahrenheit = temp.unit === 'F' ? temp.value : temp.value * 9/5 + 32;
let closest = CONE_TEMPERATURE_CHART[0];
let minDiff = Math.abs(closest.fahrenheit - fahrenheit);
for (const coneData of CONE_TEMPERATURE_CHART) {
const diff = Math.abs(coneData.fahrenheit - fahrenheit);
if (diff < minDiff) {
minDiff = diff;
closest = coneData;
}
}
return minDiff <= 100 ? closest : null; // Only suggest if within 100°F
}
/**
* Get all available cone numbers
*/
export function getAllCones(): string[] {
return CONE_TEMPERATURE_CHART.map(c => c.cone);
}
/**
* Validate if a cone number exists in the chart
*/
export function isValidCone(cone: string): boolean {
return getConeData(cone) !== null;
}
/**
* Format cone for display (e.g., "04" -> "Cone 04")
*/
export function formatCone(cone: string): string {
return `Cone ${cone}`;
}
/**
* Get common bisque firing cones
*/
export function getBisqueCones(): string[] {
return ['04', '03', '02', '01'];
}
/**
* Get common glaze firing cones
*/
export function getGlazeFiringCones(): string[] {
return ['06', '05', '04', '5', '6', '7', '8', '9', '10'];
}

View File

@@ -0,0 +1,65 @@
import { Temperature, TemperatureUnit } from '../../types';
/**
* Convert Fahrenheit to Celsius
*/
export function fahrenheitToCelsius(f: number): number {
return (f - 32) * (5 / 9);
}
/**
* Convert Celsius to Fahrenheit
*/
export function celsiusToFahrenheit(c: number): number {
return (c * 9 / 5) + 32;
}
/**
* Convert temperature between units
*/
export function convertTemperature(temp: Temperature, toUnit: TemperatureUnit): Temperature {
if (temp.unit === toUnit) {
return temp;
}
const value = temp.unit === 'F'
? fahrenheitToCelsius(temp.value)
: celsiusToFahrenheit(temp.value);
return { value: Math.round(value), unit: toUnit };
}
/**
* Format temperature for display
*/
export function formatTemperature(temp: Temperature): string {
return `${temp.value}°${temp.unit}`;
}
/**
* Convert pounds to kilograms
*/
export function poundsToKilograms(lb: number): number {
return lb * 0.453592;
}
/**
* Convert kilograms to pounds
*/
export function kilogramsToPounds(kg: number): number {
return kg / 0.453592;
}
/**
* Convert inches to centimeters
*/
export function inchesToCentimeters(inches: number): number {
return inches * 2.54;
}
/**
* Convert centimeters to inches
*/
export function centimetersToInches(cm: number): number {
return cm / 2.54;
}

76
src/lib/utils/datetime.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* Get current ISO timestamp
*/
export function now(): string {
return new Date().toISOString();
}
/**
* Format ISO date to readable string
*/
export function formatDate(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format ISO date to readable date and time
*/
export function formatDateTime(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
/**
* Get relative time string (e.g., "2 days ago")
*/
export function getRelativeTime(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
return `${Math.floor(diffDays / 365)}y ago`;
}
/**
* Format duration in minutes to h:mm
*/
export function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
}
/**
* Parse duration string (h:mm) to minutes
*/
export function parseDuration(durationString: string): number | null {
const match = durationString.match(/^(\d+):(\d{2})$/);
if (!match) return null;
const hours = parseInt(match[1], 10);
const mins = parseInt(match[2], 10);
if (mins >= 60) return null;
return hours * 60 + mins;
}

6
src/lib/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './conversions';
export * from './coneConverter';
export * from './uuid';
export * from './datetime';
export * from './stepOrdering';
export * from './colorMixing';

View File

@@ -0,0 +1,93 @@
import { Step, StepType } from '../../types';
/**
* Defines the logical order of pottery steps in the creation process
*/
const STEP_ORDER: StepType[] = [
'forming',
'trimming',
'drying',
'bisque_firing',
'glazing',
'glaze_firing',
'misc',
];
/**
* Get the order index for a step type (lower = earlier in process)
*/
export function getStepOrderIndex(stepType: StepType): number {
const index = STEP_ORDER.indexOf(stepType);
return index === -1 ? 999 : index;
}
/**
* Sort steps by their logical order in the pottery process
* Steps of the same type are sorted by creation date
*/
export function sortStepsByLogicalOrder(steps: Step[]): Step[] {
return [...steps].sort((a, b) => {
const orderA = getStepOrderIndex(a.type);
const orderB = getStepOrderIndex(b.type);
if (orderA !== orderB) {
return orderA - orderB;
}
// Same step type, sort by creation date
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
}
/**
* Get the next suggested step based on completed steps
* Returns null if all typical steps are completed
*/
export function suggestNextStep(completedSteps: Step[]): StepType | null {
const completedTypes = new Set(completedSteps.map(s => s.type));
// Find first step type not yet completed
for (const stepType of STEP_ORDER) {
if (stepType === 'misc') continue; // Skip misc, it's optional
if (!completedTypes.has(stepType)) {
return stepType;
}
}
return null; // All steps completed
}
/**
* Check if steps are in a reasonable order
* Returns warnings for potentially out-of-order steps
*/
export function validateStepOrder(steps: Step[]): string[] {
const warnings: string[] = [];
const stepIndices = steps.map(s => ({ type: s.type, order: getStepOrderIndex(s.type) }));
// Check for major order violations (e.g., glazing before bisque firing)
for (let i = 0; i < stepIndices.length - 1; i++) {
const current = stepIndices[i];
const next = stepIndices[i + 1];
// If we go backwards by more than 1 step (allowing for some flexibility)
if (current.order > next.order + 1) {
warnings.push(`${current.type} typically comes before ${next.type}`);
}
}
// Specific pottery logic checks
const hasGlazing = steps.some(s => s.type === 'glazing');
const hasBisque = steps.some(s => s.type === 'bisque_firing');
const hasGlazeFiring = steps.some(s => s.type === 'glaze_firing');
if (hasGlazing && !hasBisque) {
warnings.push('Glazing typically requires bisque firing first');
}
if (hasGlazeFiring && !hasGlazing) {
warnings.push('Glaze firing typically requires glazing first');
}
return warnings;
}

10
src/lib/utils/uuid.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Generate a UUID v4
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

246
src/navigation/index.tsx Normal file
View File

@@ -0,0 +1,246 @@
import React from 'react';
import { Text, View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { RootStackParamList, MainTabParamList } from './types';
import {
LoginScreen,
SignUpScreen,
OnboardingScreen,
ProjectsScreen,
ProjectDetailScreen,
StepEditorScreen,
NewsScreen,
SettingsScreen,
GlazePickerScreen,
GlazeMixerScreen,
} from '../screens';
import { colors, spacing } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
const RootStack = createNativeStackNavigator<RootStackParamList>();
const MainTab = createBottomTabNavigator<MainTabParamList>();
function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const tabs = [
{ name: 'Projects', label: 'Projects', icon: '🏺' },
{ name: 'News', label: 'Tips', icon: '💡' },
{ name: 'Settings', label: 'Settings', icon: '⚙️' },
];
return (
<View style={styles.tabBarContainer}>
{tabs.map((tab, index) => {
const isFocused = state.index === index;
const isFirst = index === 0;
const isLast = index === tabs.length - 1;
const isMiddle = !isFirst && !isLast;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: state.routes[index].key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(state.routes[index].name);
}
};
return (
<TouchableOpacity
key={tab.name}
onPress={onPress}
style={[
styles.tabItem,
isFocused && styles.tabItemActive,
isFocused && isFirst && styles.tabItemActiveFirst,
isFocused && isLast && styles.tabItemActiveLast,
isFocused && isMiddle && styles.tabItemActiveMiddle,
]}
>
<Text style={styles.tabIcon}>{tab.icon}</Text>
<Text style={[
styles.tabLabel,
isFocused && styles.tabLabelActive
]}>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}
function MainTabs() {
return (
<MainTab.Navigator
tabBar={props => <CustomTabBar {...props} />}
screenOptions={{
headerShown: false,
}}
>
<MainTab.Screen
name="Projects"
component={ProjectsScreen}
options={{
tabBarLabel: 'Projects',
}}
/>
<MainTab.Screen
name="News"
component={NewsScreen}
options={{
tabBarLabel: 'Tips',
}}
/>
<MainTab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarLabel: 'Settings',
}}
/>
</MainTab.Navigator>
);
}
const styles = StyleSheet.create({
tabBarContainer: {
flexDirection: 'row',
backgroundColor: colors.background,
height: 90,
position: 'absolute',
bottom: 20,
left: 10,
right: 10,
borderRadius: 25,
shadowColor: colors.text,
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 8,
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
},
tabItemActive: {
backgroundColor: '#E8C7A8',
borderWidth: 2,
borderColor: '#e6b98e',
},
tabItemActiveFirst: {
borderRadius: 25,
},
tabItemActiveLast: {
borderRadius: 25,
},
tabItemActiveMiddle: {
borderRadius: 25,
},
tabIcon: {
fontSize: 32,
marginBottom: 4,
},
tabLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.3,
color: colors.textSecondary,
},
tabLabelActive: {
color: colors.text,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
},
});
export function AppNavigator() {
const { user, loading, hasCompletedOnboarding } = useAuth();
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<NavigationContainer>
<RootStack.Navigator
initialRouteName={!user ? 'Login' : hasCompletedOnboarding ? 'MainTabs' : 'Onboarding'}
screenOptions={{
headerStyle: {
backgroundColor: colors.background,
},
headerTintColor: colors.primary,
headerTitleStyle: {
fontWeight: '600',
},
}}
>
{!user ? (
// Auth screens - not logged in
<>
<RootStack.Screen
name="Login"
component={LoginScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="SignUp"
component={SignUpScreen}
options={{ headerShown: false }}
/>
</>
) : (
// App screens - logged in (initial route determined by hasCompletedOnboarding)
<>
<RootStack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="MainTabs"
component={MainTabs}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="ProjectDetail"
component={ProjectDetailScreen}
options={{ title: 'Project' }}
/>
<RootStack.Screen
name="StepEditor"
component={StepEditorScreen}
options={{ title: 'Add Step' }}
/>
<RootStack.Screen
name="GlazePicker"
component={GlazePickerScreen}
options={{ title: 'Select Glazes' }}
/>
<RootStack.Screen
name="GlazeMixer"
component={GlazeMixerScreen}
options={{ title: 'Mix Glazes' }}
/>
</>
)}
</RootStack.Navigator>
</NavigationContainer>
);
}

26
src/navigation/types.ts Normal file
View File

@@ -0,0 +1,26 @@
import { NavigatorScreenParams } from '@react-navigation/native';
export type RootStackParamList = {
Login: undefined;
SignUp: undefined;
Onboarding: undefined;
MainTabs: NavigatorScreenParams<MainTabParamList>;
ProjectDetail: { projectId: string };
StepEditor: { projectId: string; stepId?: string; selectedGlazeIds?: string[]; mixNotes?: string; _timestamp?: number; _editorKey?: string };
GlazePicker: { projectId: string; stepId?: string; selectedGlazeIds?: string[]; _editorKey?: string };
GlazeMixer: { projectId: string; stepId?: string; _editorKey?: string };
GlazeDetail: { glazeId: string };
AddCustomGlaze: { onGlazeCreated?: (glazeId: string) => void };
};
export type MainTabParamList = {
Projects: undefined;
News: undefined;
Settings: undefined;
};
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

View File

@@ -0,0 +1,365 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Glaze } from '../types';
import { getAllGlazes, searchGlazes } from '../lib/db/repositories';
import { Button, Card } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { mixColors, generateMixName } from '../lib/utils';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'GlazeMixer'>;
type RouteProps = RouteProp<RootStackParamList, 'GlazeMixer'>;
export const GlazeMixerScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteProps>();
const { user } = useAuth();
const [glazes, setGlazes] = useState<Glaze[]>([]);
const [selectedGlazes, setSelectedGlazes] = useState<Glaze[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [mixNotes, setMixNotes] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadGlazes();
}, []);
useEffect(() => {
if (searchQuery.trim()) {
searchGlazesHandler();
} else {
loadGlazes();
}
}, [searchQuery]);
const loadGlazes = async () => {
if (!user) return;
try {
const all = await getAllGlazes(user.id);
setGlazes(all);
} catch (error) {
console.error('Failed to load glazes:', error);
} finally {
setLoading(false);
}
};
const searchGlazesHandler = async () => {
if (!user) return;
try {
const results = await searchGlazes(searchQuery, user.id);
setGlazes(results);
} catch (error) {
console.error('Failed to search glazes:', error);
}
};
const toggleGlaze = (glaze: Glaze) => {
const isSelected = selectedGlazes.some(g => g.id === glaze.id);
if (isSelected) {
setSelectedGlazes(selectedGlazes.filter(g => g.id !== glaze.id));
} else {
if (selectedGlazes.length >= 3) {
Alert.alert('Limit Reached', 'You can mix up to 3 glazes at once');
return;
}
setSelectedGlazes([...selectedGlazes, glaze]);
}
};
const getMixedColor = (): string => {
const colorsToMix = selectedGlazes
.map(g => g.color)
.filter(c => c !== undefined) as string[];
if (colorsToMix.length === 0) {
return '#808080'; // Default gray
}
return mixColors(colorsToMix);
};
const handleCreateMix = () => {
if (selectedGlazes.length < 2) {
Alert.alert('Select More Glazes', 'Please select at least 2 glazes to mix');
return;
}
// Navigate back to the EXACT StepEditor instance we came from
// Using the same _editorKey ensures React Navigation finds the correct instance
console.log('GlazeMixer: Navigating back to StepEditor with mixed glazes:', selectedGlazes.map(g => g.id));
navigation.navigate({
name: 'StepEditor',
params: {
projectId: route.params.projectId,
stepId: route.params.stepId,
selectedGlazeIds: selectedGlazes.map(g => g.id),
mixNotes: mixNotes || undefined,
_editorKey: route.params._editorKey, // Same key = same instance!
_timestamp: Date.now(),
},
merge: true, // Merge params with existing screen instead of creating new one
} as any);
};
// Removed complex scroll handler to eliminate lag
const renderGlaze = ({ item }: { item: Glaze }) => {
const isSelected = selectedGlazes.some(g => g.id === item.id);
return (
<TouchableOpacity onPress={() => toggleGlaze(item)}>
<Card style={[styles.glazeCard, isSelected ? styles.glazeCardSelected : null]}>
<View style={styles.glazeMainContent}>
{item.color && (
<View style={[styles.colorPreview, { backgroundColor: item.color }]} />
)}
<View style={styles.glazeInfo}>
<View style={styles.glazeHeader}>
<Text style={styles.glazeBrand}>{item.brand}</Text>
</View>
<Text style={styles.glazeName}>{item.name}</Text>
{item.code && <Text style={styles.glazeCode}>Code: {item.code}</Text>}
</View>
</View>
</Card>
</TouchableOpacity>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TextInput
style={styles.searchInput}
placeholder="Search glazes..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor={colors.textSecondary}
/>
</View>
{selectedGlazes.length > 0 && (
<View style={styles.mixPreviewContainer}>
<Card style={styles.mixPreviewCard}>
<Text style={styles.mixPreviewTitle}>Mix Preview</Text>
<View style={styles.mixPreviewContent}>
<View style={[styles.mixedColorPreview, { backgroundColor: getMixedColor() }]} />
<View style={styles.mixInfo}>
<Text style={styles.mixedGlazesText}>
{selectedGlazes.map(g => g.name).join(' + ')}
</Text>
<Text style={styles.glazeCount}>
{selectedGlazes.length} glaze{selectedGlazes.length !== 1 ? 's' : ''} selected
</Text>
</View>
</View>
<TextInput
style={styles.notesInput}
placeholder="Mix notes (e.g., '50/50', '3:1 ratio')"
value={mixNotes}
onChangeText={setMixNotes}
placeholderTextColor={colors.textSecondary}
/>
</Card>
</View>
)}
<Text style={styles.selectedCount}>
Select 2-3 glazes to mix ({selectedGlazes.length}/3)
</Text>
<FlatList
data={glazes}
renderItem={renderGlaze}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchQuery ? 'No glazes found' : 'No glazes in catalog'}
</Text>
</View>
}
/>
<View style={styles.footer}>
<Button
title="Save Mix"
onPress={handleCreateMix}
disabled={selectedGlazes.length < 2}
/>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.md,
backgroundColor: colors.background,
},
searchInput: {
backgroundColor: colors.backgroundSecondary,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: typography.fontSize.md,
color: colors.text,
},
mixPreviewContainer: {
position: 'absolute',
top: 60, // Below search header
left: 0,
right: 0,
zIndex: 500,
},
mixPreviewCard: {
marginHorizontal: spacing.md,
marginVertical: spacing.md,
backgroundColor: colors.background, // Solid background instead of transparent
borderWidth: 2,
borderColor: colors.primary,
},
mixPreviewTitle: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
mixPreviewContent: {
flexDirection: 'row',
gap: spacing.md,
marginBottom: spacing.md,
},
mixedColorPreview: {
width: 80,
height: 80,
borderRadius: borderRadius.md,
borderWidth: 3,
borderColor: colors.border,
},
mixInfo: {
flex: 1,
justifyContent: 'center',
},
mixedGlazesText: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.xs,
},
glazeCount: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
notesInput: {
backgroundColor: colors.background,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
color: colors.text,
},
selectedCount: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
textTransform: 'uppercase',
},
listContent: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl,
},
glazeCard: {
marginBottom: spacing.md,
},
glazeCardSelected: {
borderWidth: 3,
borderColor: colors.primary,
backgroundColor: colors.primaryLight + '20',
},
glazeMainContent: {
flexDirection: 'row',
gap: spacing.md,
},
colorPreview: {
width: 50,
height: 50,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
},
glazeInfo: {
flex: 1,
},
glazeHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
glazeBrand: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
glazeName: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.xs,
},
glazeCode: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
emptyContainer: {
padding: spacing.xl,
alignItems: 'center',
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
},
footer: {
padding: spacing.md,
backgroundColor: colors.background,
borderTopWidth: 2,
borderTopColor: colors.border,
},
});

View File

@@ -0,0 +1,311 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Glaze } from '../types';
import { getAllGlazes, searchGlazes } from '../lib/db/repositories';
import { Button, Card } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'GlazePicker'>;
type RouteProps = RouteProp<RootStackParamList, 'GlazePicker'>;
export const GlazePickerScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteProps>();
const { user } = useAuth();
const { selectedGlazeIds = [] } = route.params || {};
const [glazes, setGlazes] = useState<Glaze[]>([]);
const [selected, setSelected] = useState<string[]>(selectedGlazeIds);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadGlazes();
}, []);
useEffect(() => {
if (searchQuery.trim()) {
searchGlazesHandler();
} else {
loadGlazes();
}
}, [searchQuery]);
const loadGlazes = async () => {
if (!user) return;
try {
const all = await getAllGlazes(user.id);
setGlazes(all);
} catch (error) {
console.error('Failed to load glazes:', error);
} finally {
setLoading(false);
}
};
const searchGlazesHandler = async () => {
if (!user) return;
try {
const results = await searchGlazes(searchQuery, user.id);
setGlazes(results);
} catch (error) {
console.error('Failed to search glazes:', error);
}
};
const toggleGlaze = (glazeId: string) => {
if (selected.includes(glazeId)) {
setSelected(selected.filter(id => id !== glazeId));
} else {
setSelected([...selected, glazeId]);
}
};
const handleSave = () => {
// Navigate back to the EXACT StepEditor instance we came from
// Using the same _editorKey ensures React Navigation finds the correct instance
console.log('GlazePicker: Navigating back to StepEditor with glazes:', selected);
navigation.navigate({
name: 'StepEditor',
params: {
projectId: route.params.projectId,
stepId: route.params.stepId,
selectedGlazeIds: selected,
_editorKey: route.params._editorKey, // Same key = same instance!
_timestamp: Date.now(),
},
merge: true, // Merge params with existing screen instead of creating new one
} as any);
};
const renderGlaze = ({ item }: { item: Glaze }) => {
const isSelected = selected.includes(item.id);
return (
<TouchableOpacity onPress={() => toggleGlaze(item.id)}>
<Card style={[styles.glazeCard, isSelected ? styles.glazeCardSelected : null]}>
<View style={styles.glazeMainContent}>
{item.color && (
<View style={[styles.colorPreview, { backgroundColor: item.color }]} />
)}
<View style={styles.glazeInfo}>
<View style={styles.glazeHeader}>
<Text style={styles.glazeBrand}>{item.brand}</Text>
<View style={styles.badges}>
{item.isMix && <Text style={styles.mixBadge}>Mix</Text>}
{item.isCustom && !item.isMix && <Text style={styles.customBadge}>Custom</Text>}
</View>
</View>
<Text style={styles.glazeName}>{item.name}</Text>
{item.code && <Text style={styles.glazeCode}>Code: {item.code}</Text>}
{item.finish && (
<Text style={styles.glazeFinish}>
{item.finish.charAt(0).toUpperCase() + item.finish.slice(1)}
</Text>
)}
{item.mixRatio && (
<Text style={styles.glazeMixRatio}>Ratio: {item.mixRatio}</Text>
)}
</View>
</View>
</Card>
</TouchableOpacity>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TextInput
style={styles.searchInput}
placeholder="Search glazes..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor={colors.textSecondary}
/>
</View>
<Text style={styles.selectedCount}>
Selected: {selected.length} glaze{selected.length !== 1 ? 's' : ''}
</Text>
<FlatList
data={glazes}
renderItem={renderGlaze}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchQuery ? 'No glazes found' : 'No glazes in catalog'}
</Text>
</View>
}
/>
<View style={styles.footer}>
<Button
title="Mix Glazes"
onPress={() => navigation.navigate('GlazeMixer', {
projectId: route.params.projectId,
stepId: route.params.stepId,
_editorKey: route.params._editorKey, // Pass editor key through
})}
variant="outline"
style={styles.mixButton}
/>
<Button
title="Save Selection"
onPress={handleSave}
disabled={selected.length === 0}
/>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.md,
backgroundColor: colors.background,
},
searchInput: {
backgroundColor: colors.backgroundSecondary,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: typography.fontSize.md,
color: colors.text,
},
selectedCount: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
textTransform: 'uppercase',
},
listContent: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl,
},
glazeCard: {
marginBottom: spacing.md,
},
glazeCardSelected: {
borderWidth: 3,
borderColor: colors.primary,
backgroundColor: colors.primaryLight + '20',
},
glazeMainContent: {
flexDirection: 'row',
gap: spacing.md,
},
colorPreview: {
width: 50,
height: 50,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
},
glazeInfo: {
flex: 1,
},
glazeHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
glazeBrand: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
badges: {
flexDirection: 'row',
gap: spacing.xs,
},
customBadge: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.background,
backgroundColor: colors.info,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.full,
},
mixBadge: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.background,
backgroundColor: colors.primary,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.full,
},
glazeName: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.xs,
},
glazeCode: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
glazeFinish: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
fontStyle: 'italic',
},
glazeMixRatio: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
fontStyle: 'italic',
},
emptyContainer: {
padding: spacing.xl,
alignItems: 'center',
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
},
footer: {
padding: spacing.md,
backgroundColor: colors.background,
borderTopWidth: 2,
borderTopColor: colors.border,
flexDirection: 'row',
gap: spacing.md,
},
mixButton: {
flex: 1,
},
});

153
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Button, Input } from '../components';
import { colors, spacing, typography } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Login'>;
export const LoginScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter both email and password');
return;
}
setLoading(true);
try {
await signIn(email.trim(), password);
// Navigation will be handled automatically by auth state change
} catch (error) {
Alert.alert('Login Failed', error instanceof Error ? error.message : 'Invalid credentials');
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.logo}>🏺</Text>
<Text style={styles.title}>Pottery Diary</Text>
<Text style={styles.subtitle}>Track your ceramic journey</Text>
</View>
<View style={styles.form}>
<Input
label="Email"
value={email}
onChangeText={setEmail}
placeholder="your@email.com"
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
autoComplete="password"
/>
<Button
title="Login"
onPress={handleLogin}
loading={loading}
style={styles.loginButton}
/>
<View style={styles.signupContainer}>
<Text style={styles.signupText}>Don't have an account? </Text>
<TouchableOpacity onPress={() => navigation.navigate('SignUp')}>
<Text style={styles.signupLink}>Sign Up</Text>
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: spacing.xl,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xl * 2,
},
logo: {
fontSize: 80,
marginBottom: spacing.md,
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
marginBottom: spacing.xs,
letterSpacing: 1,
},
subtitle: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
letterSpacing: 0.5,
},
form: {
width: '100%',
},
loginButton: {
marginTop: spacing.md,
},
signupContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing.lg,
},
signupText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
},
signupLink: {
fontSize: typography.fontSize.md,
color: colors.primary,
fontWeight: typography.fontWeight.bold,
},
});

234
src/screens/NewsScreen.tsx Normal file
View File

@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Card } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
interface Tip {
id: string;
title: string;
content: string;
category: string;
icon: string;
}
const POTTERY_TIPS: Tip[] = [
{
id: '1',
title: 'Wedging Clay',
content: 'Always wedge your clay thoroughly before throwing to remove air bubbles and ensure consistent texture. Aim for 50-100 wedges.',
category: 'Basics',
icon: '🏺',
},
{
id: '2',
title: 'Drying Time',
content: 'Dry pieces slowly and evenly. Cover with plastic for controlled drying. Uneven drying leads to cracks!',
category: 'Drying',
icon: '☀️',
},
{
id: '3',
title: 'Bisque Firing',
content: 'Bisque fire to Cone 04 (1945°F) for most clay bodies. This makes pieces porous and ready for glazing.',
category: 'Firing',
icon: '🔥',
},
{
id: '4',
title: 'Glaze Application',
content: 'Apply 2-3 coats of glaze for best results. Too thin = bare spots, too thick = running. Test on tiles first!',
category: 'Glazing',
icon: '🎨',
},
{
id: '5',
title: 'Cone 6 is Popular',
content: 'Cone 6 (2232°F) is the most common mid-range firing temperature. Great balance of durability and glaze options.',
category: 'Firing',
icon: '⚡',
},
{
id: '6',
title: 'Document Everything',
content: 'Keep detailed notes! Record clay type, glaze brands, cone numbers, and firing schedules. This app helps!',
category: 'Best Practice',
icon: '📝',
},
{
id: '7',
title: 'Test Tiles',
content: 'Always make test tiles for new glaze combinations. Save yourself from ruined pieces!',
category: 'Glazing',
icon: '🧪',
},
{
id: '8',
title: 'Thickness Matters',
content: 'Keep walls consistent - about 1/4 to 3/8 inch thick. Thinner walls fire more evenly.',
category: 'Forming',
icon: '📏',
},
];
export const NewsScreen: React.FC = () => {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = Array.from(new Set(POTTERY_TIPS.map(tip => tip.category)));
const filteredTips = selectedCategory
? POTTERY_TIPS.filter(tip => tip.category === selectedCategory)
: POTTERY_TIPS;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Tips & Tricks</Text>
<Text style={styles.headerSubtitle}>Learn ceramics best practices</Text>
</View>
<View style={styles.filterContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<TouchableOpacity
style={[styles.filterButton, !selectedCategory && styles.filterButtonActive]}
onPress={() => setSelectedCategory(null)}
>
<Text style={[styles.filterText, !selectedCategory && styles.filterTextActive]}>
All
</Text>
</TouchableOpacity>
{categories.map(category => (
<TouchableOpacity
key={category}
style={[
styles.filterButton,
selectedCategory === category && styles.filterButtonActive,
]}
onPress={() => setSelectedCategory(category)}
>
<Text
style={[
styles.filterText,
selectedCategory === category && styles.filterTextActive,
]}
>
{category}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.listContent}>
{filteredTips.map((tip) => (
<Card key={tip.id} style={styles.tipCard}>
<View style={styles.tipHeader}>
<Text style={styles.tipIcon}>{tip.icon}</Text>
<View style={styles.tipHeaderText}>
<Text style={styles.tipTitle}>{tip.title}</Text>
<Text style={styles.tipCategory}>{tip.category}</Text>
</View>
</View>
<Text style={styles.tipContent}>{tip.content}</Text>
</Card>
))}
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: spacing.lg,
backgroundColor: colors.background,
borderBottomWidth: 3,
borderBottomColor: colors.primary,
},
headerTitle: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
headerSubtitle: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
},
filterContainer: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
backgroundColor: colors.background,
},
filterButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
marginRight: spacing.sm,
},
filterButtonActive: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
filterText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.3,
},
filterTextActive: {
color: colors.text,
},
content: {
flex: 1,
},
listContent: {
paddingHorizontal: spacing.md,
paddingTop: spacing.lg,
paddingBottom: 100,
},
tipCard: {
marginBottom: spacing.md,
},
tipHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
gap: spacing.md,
},
tipIcon: {
fontSize: 32,
},
tipHeaderText: {
flex: 1,
},
tipTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.3,
marginBottom: spacing.xs,
},
tipCategory: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
tipContent: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Image } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Button } from '../components';
import { colors, spacing, typography } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Onboarding'>;
const SLIDES = [
{
title: 'Welcome to Pottery Diary',
description: 'Track every step of your ceramics journey from clay to finished piece',
icon: '🏺',
},
{
title: 'Log Every Detail',
description: 'Record firing temps, cone numbers, glazes, layers, and photos for each project',
icon: '🔥',
},
{
title: 'Reproduce Your Results',
description: 'Never forget how you made that perfect glaze combination',
icon: '🎨',
},
];
export const OnboardingScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { completeOnboarding } = useAuth();
const [currentSlide, setCurrentSlide] = useState(0);
const handleNext = () => {
if (currentSlide < SLIDES.length - 1) {
setCurrentSlide(currentSlide + 1);
} else {
handleComplete();
}
};
const handleSkip = () => {
handleComplete();
};
const handleComplete = async () => {
// Save onboarding completion status
await completeOnboarding();
navigation.replace('MainTabs', { screen: 'Projects' });
};
const slide = SLIDES[currentSlide];
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.icon}>{slide.icon}</Text>
<Text style={styles.title}>{slide.title}</Text>
<Text style={styles.description}>{slide.description}</Text>
</View>
<View style={styles.pagination}>
{SLIDES.map((_, index) => (
<View
key={index}
style={[styles.dot, index === currentSlide && styles.dotActive]}
/>
))}
</View>
<View style={styles.buttons}>
{currentSlide < SLIDES.length - 1 && (
<Button
title="Skip"
onPress={handleSkip}
variant="ghost"
/>
)}
<Button
title={currentSlide === SLIDES.length - 1 ? "Get Started" : "Next"}
onPress={handleNext}
style={styles.nextButton}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: spacing.xl,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
icon: {
fontSize: 80,
marginBottom: spacing.xl,
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: typography.fontWeight.bold,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.md,
},
description: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: 24,
},
pagination: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
marginBottom: spacing.lg,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.border,
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
},
nextButton: {
flex: 1,
},
});

View File

@@ -0,0 +1,599 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Alert,
Image,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import * as ImagePicker from 'expo-image-picker';
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Project, Step } from '../types';
import {
getProject,
createProject,
updateProject,
deleteProject,
getStepsByProject,
} from '../lib/db/repositories';
import { Button, Input, Card } from '../components';
import { colors, spacing, typography, stepTypeIcons, stepTypeLabels, borderRadius } from '../lib/theme';
import { formatDate } from '../lib/utils/datetime';
import { sortStepsByLogicalOrder, suggestNextStep } from '../lib/utils/stepOrdering';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'ProjectDetail'>;
type RouteProps = RouteProp<RootStackParamList, 'ProjectDetail'>;
export const ProjectDetailScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteProps>();
const { user } = useAuth();
const isNew = route.params.projectId === 'new';
const [title, setTitle] = useState('');
const [status, setStatus] = useState<Project['status']>('in_progress');
const [tags, setTags] = useState('');
const [coverImage, setCoverImage] = useState<string | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isNew) {
loadProject();
}
}, [route.params.projectId]);
// Reload steps whenever screen comes into focus (for instant save display)
useFocusEffect(
React.useCallback(() => {
if (!isNew) {
console.log('ProjectDetail focused - reloading steps');
loadSteps();
}
}, [route.params.projectId, isNew])
);
const loadSteps = async () => {
try {
const projectSteps = await getStepsByProject(route.params.projectId);
const sortedSteps = sortStepsByLogicalOrder(projectSteps);
setSteps(sortedSteps);
console.log('✅ Steps reloaded, count:', sortedSteps.length);
} catch (error) {
console.error('Failed to reload steps:', error);
}
};
const loadProject = async () => {
try {
const project = await getProject(route.params.projectId);
if (project) {
setTitle(project.title);
setStatus(project.status);
setTags(project.tags.join(', '));
setCoverImage(project.coverImageUri || null);
const projectSteps = await getStepsByProject(project.id);
const sortedSteps = sortStepsByLogicalOrder(projectSteps);
setSteps(sortedSteps);
}
} catch (error) {
console.error('Failed to load project:', error);
} finally {
setLoading(false);
}
};
const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'We need camera permissions to take photos');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setCoverImage(result.assets[0].uri);
}
};
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'We need camera roll permissions to add photos');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setCoverImage(result.assets[0].uri);
}
};
const handleAddCoverImage = () => {
Alert.alert(
'Add Cover Photo',
'Choose a source',
[
{ text: 'Camera', onPress: takePhoto },
{ text: 'Gallery', onPress: pickImage },
{ text: 'Cancel', style: 'cancel' },
]
);
};
const handleSave = async () => {
if (!title.trim()) {
Alert.alert('Error', 'Please enter a project title');
return;
}
if (!user) {
Alert.alert('Error', 'User not authenticated');
return;
}
setSaving(true);
try {
const tagArray = tags.split(',').map(t => t.trim()).filter(t => t);
if (isNew) {
await createProject(user.id, title, tagArray, status, coverImage || undefined);
} else {
await updateProject(route.params.projectId, {
title,
status,
tags: tagArray,
coverImageUri: coverImage || undefined,
});
}
// Navigate to home after successful save
navigation.navigate('MainTabs', { screen: 'Projects' });
} catch (error) {
console.error('Failed to save project:', error);
Alert.alert('Error', 'Failed to save project');
} finally {
setSaving(false);
}
};
const handleDelete = () => {
Alert.alert(
'Delete Project',
'Are you sure you want to delete this project?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await deleteProject(route.params.projectId);
navigation.goBack();
},
},
]
);
};
const handleAddStep = () => {
navigation.navigate('StepEditor', { projectId: route.params.projectId });
};
const handleEditStep = (stepId: string) => {
navigation.navigate('StepEditor', { projectId: route.params.projectId, stepId });
};
if (loading) {
return (
<View style={styles.centerContainer}>
<Text>Loading...</Text>
</View>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<KeyboardAwareScrollView
style={styles.container}
contentContainerStyle={styles.content}
enableOnAndroid={true}
enableAutomaticScroll={true}
extraScrollHeight={100}
keyboardShouldPersistTaps="handled"
>
<Card>
<Text style={styles.label}>Cover Image</Text>
<TouchableOpacity style={styles.imagePicker} onPress={handleAddCoverImage}>
{coverImage ? (
<Image source={{ uri: coverImage }} style={styles.coverImagePreview} />
) : (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>📷</Text>
<Text style={styles.imagePlaceholderLabel}>Add Cover Photo</Text>
</View>
)}
</TouchableOpacity>
<Input
label="Project Title"
value={title}
onChangeText={setTitle}
placeholder="Enter project name"
/>
<Input
label="Tags (comma-separated)"
value={tags}
onChangeText={setTags}
placeholder="e.g., bowl, cone 6, blue glaze"
/>
<Text style={styles.label}>Status</Text>
<View style={styles.statusContainer}>
<TouchableOpacity
style={[
styles.statusButton,
status === 'in_progress' && styles.statusButtonActive,
styles.statusButtonInProgress,
]}
onPress={() => setStatus('in_progress')}
>
<Text style={[
styles.statusButtonText,
status === 'in_progress' && styles.statusButtonTextActive
]}>
In Progress
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.statusButton,
status === 'done' && styles.statusButtonActive,
styles.statusButtonDone,
]}
onPress={() => setStatus('done')}
>
<Text style={[
styles.statusButtonText,
status === 'done' && styles.statusButtonTextActive
]}>
Done
</Text>
</TouchableOpacity>
</View>
<Button
title={isNew ? 'Create Project' : 'Save Changes'}
onPress={handleSave}
loading={saving}
/>
{!isNew && (
<Button
title="Delete Project"
onPress={handleDelete}
variant="outline"
style={styles.deleteButton}
/>
)}
</Card>
{!isNew && (
<>
<View style={styles.stepsHeader}>
<Text style={styles.sectionTitle}>Process Steps</Text>
<Button
title="Add Step"
onPress={handleAddStep}
size="sm"
/>
</View>
{steps.length > 0 && status === 'in_progress' && (() => {
const nextStep = suggestNextStep(steps);
return nextStep ? (
<Card style={styles.suggestionCard}>
<View style={styles.suggestionHeader}>
<Text style={styles.suggestionIcon}>💡</Text>
<Text style={styles.suggestionTitle}>Suggested Next Step</Text>
</View>
<View style={styles.suggestionContent}>
<Text style={styles.suggestionStepIcon}>{stepTypeIcons[nextStep]}</Text>
<Text style={styles.suggestionStepLabel}>{stepTypeLabels[nextStep]}</Text>
</View>
</Card>
) : null;
})()}
{steps.length === 0 ? (
<Card>
<Text style={styles.emptyText}>No steps yet. Add your first step!</Text>
</Card>
) : (
<View style={styles.timeline}>
{steps.map((step, index) => (
<View key={step.id} style={styles.timelineItem}>
{/* Timeline dot and line */}
<View style={styles.timelineLeft}>
<View style={styles.timelineDot}>
<Text style={styles.timelineDotIcon}>{stepTypeIcons[step.type]}</Text>
</View>
{index < steps.length - 1 && <View style={styles.timelineLine} />}
</View>
{/* Step card */}
<TouchableOpacity
style={styles.timelineRight}
onPress={() => handleEditStep(step.id)}
activeOpacity={0.7}
>
<Card style={styles.stepCard}>
<View style={styles.stepHeader}>
<View style={styles.stepHeaderText}>
<Text style={styles.stepType}>{stepTypeLabels[step.type]}</Text>
{step.photoUris && step.photoUris.length > 0 && (
<Text style={styles.photoCount}>📷 {step.photoUris.length}</Text>
)}
</View>
</View>
<Text style={styles.stepDate}>{formatDate(step.createdAt)}</Text>
{step.notesMarkdown && (
<Text style={styles.stepNotes} numberOfLines={2}>
{step.notesMarkdown}
</Text>
)}
</Card>
</TouchableOpacity>
</View>
))}
</View>
)}
</>
)}
</KeyboardAwareScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
container: {
flex: 1,
},
content: {
paddingHorizontal: spacing.md,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
deleteButton: {
marginTop: spacing.md,
},
stepsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: spacing.lg,
marginBottom: spacing.md,
},
sectionTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
timeline: {
paddingTop: spacing.sm,
},
timelineItem: {
flexDirection: 'row',
marginBottom: spacing.md,
},
timelineLeft: {
alignItems: 'center',
width: 60,
marginRight: spacing.md,
},
timelineDot: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primary,
borderWidth: 3,
borderColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
timelineDotIcon: {
fontSize: 20,
},
timelineLine: {
width: 3,
flex: 1,
backgroundColor: colors.border,
marginTop: spacing.xs,
marginBottom: spacing.xs,
},
timelineRight: {
flex: 1,
paddingTop: spacing.xs,
},
stepCard: {
marginBottom: 0,
},
stepHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
stepHeaderText: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
stepType: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.3,
},
photoCount: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
fontWeight: typography.fontWeight.bold,
},
stepDate: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
},
stepNotes: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.sm,
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
textAlign: 'center',
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.sm,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
imagePicker: {
marginBottom: spacing.lg,
borderRadius: borderRadius.md,
overflow: 'hidden',
borderWidth: 2,
borderColor: colors.border,
},
coverImagePreview: {
width: '100%',
height: 200,
backgroundColor: colors.backgroundSecondary,
},
imagePlaceholder: {
width: '100%',
height: 200,
backgroundColor: colors.backgroundSecondary,
justifyContent: 'center',
alignItems: 'center',
},
imagePlaceholderText: {
fontSize: 48,
marginBottom: spacing.sm,
},
imagePlaceholderLabel: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
fontWeight: typography.fontWeight.bold,
},
statusContainer: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.lg,
},
statusButton: {
flex: 1,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundSecondary,
},
statusButtonActive: {
borderWidth: 3,
},
statusButtonInProgress: {
borderColor: '#E8A87C',
},
statusButtonDone: {
borderColor: '#85CDCA',
},
statusButtonArchived: {
borderColor: '#C38D9E',
},
statusButtonText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.2,
textTransform: 'uppercase',
},
statusButtonTextActive: {
color: colors.text,
},
suggestionCard: {
marginBottom: spacing.md,
backgroundColor: colors.primaryLight,
borderWidth: 2,
borderColor: colors.primary,
},
suggestionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm,
},
suggestionIcon: {
fontSize: 20,
},
suggestionTitle: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
suggestionContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
},
suggestionStepIcon: {
fontSize: 28,
},
suggestionStepLabel: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
},
});

View File

@@ -0,0 +1,490 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
Image,
RefreshControl,
TextInput,
} from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Project } from '../types';
import { getAllProjects } from '../lib/db/repositories';
import { Button, Card } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { formatDate } from '../lib/utils/datetime';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type SortOption = 'newest' | 'oldest' | 'name';
export const ProjectsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { user } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [searchQuery, setSearchQuery] = useState('');
const sortProjects = (projectsToSort: Project[]) => {
const sorted = [...projectsToSort];
switch (sortBy) {
case 'newest':
return sorted.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
case 'oldest':
return sorted.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
case 'name':
return sorted.sort((a, b) => a.title.localeCompare(b.title));
default:
return sorted;
}
};
const filterProjects = (projectsToFilter: Project[], query: string) => {
if (!query.trim()) return projectsToFilter;
const lowerQuery = query.toLowerCase();
return projectsToFilter.filter(project =>
project.title.toLowerCase().includes(lowerQuery) ||
project.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
);
};
const loadProjects = async () => {
if (!user) return;
try {
const allProjects = await getAllProjects(user.id);
const sorted = sortProjects(allProjects);
setProjects(sorted);
setFilteredProjects(filterProjects(sorted, searchQuery));
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useFocusEffect(
useCallback(() => {
loadProjects();
}, [])
);
useEffect(() => {
if (projects.length > 0) {
const sorted = sortProjects(projects);
setProjects(sorted);
setFilteredProjects(filterProjects(sorted, searchQuery));
}
}, [sortBy]);
useEffect(() => {
setFilteredProjects(filterProjects(projects, searchQuery));
}, [searchQuery]);
const handleRefresh = () => {
setRefreshing(true);
loadProjects();
};
const handleCreateProject = () => {
navigation.navigate('ProjectDetail', { projectId: 'new' });
};
const handleProjectPress = (projectId: string) => {
navigation.navigate('ProjectDetail', { projectId });
};
const getStepCount = (projectId: string) => {
// This will be calculated from actual steps
return 0; // Placeholder
};
const renderProject = ({ item }: { item: Project }) => {
const stepCount = getStepCount(item.id);
return (
<TouchableOpacity
onPress={() => handleProjectPress(item.id)}
accessibilityRole="button"
accessibilityLabel={`Open project ${item.title}`}
>
<Card style={styles.projectCard}>
{item.coverImageUri && (
<View style={styles.imageContainer}>
<Image
source={{ uri: item.coverImageUri }}
style={styles.coverImage}
accessibilityLabel={`Cover image for ${item.title}`}
/>
{stepCount > 0 && (
<View style={styles.stepCountBadge}>
<Text style={styles.stepCountText}>{stepCount} steps</Text>
</View>
)}
</View>
)}
<View style={styles.projectInfo}>
<View style={styles.titleRow}>
<Text style={styles.projectTitle}>{item.title}</Text>
</View>
<View style={styles.projectMeta}>
<View style={[
styles.statusBadge,
item.status === 'in_progress' && styles.statusBadgeInProgress,
item.status === 'done' && styles.statusBadgeDone,
]}>
<Text style={styles.statusText}>
{item.status === 'in_progress' ? 'In Progress' : 'Done'}
</Text>
</View>
<Text style={styles.date}>{formatDate(item.updatedAt)}</Text>
</View>
{item.tags.length > 0 && (
<View style={styles.tags}>
{item.tags.map((tag, index) => (
<View key={index} style={styles.tag}>
<Text style={styles.tagText}>{tag}</Text>
</View>
))}
</View>
)}
</View>
</Card>
</TouchableOpacity>
);
};
if (loading) {
return (
<View style={styles.centerContainer}>
<Text>Loading projects...</Text>
</View>
);
}
return (
<View style={styles.container}>
{filteredProjects.length === 0 && searchQuery.length > 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>No matches found</Text>
<Text style={styles.emptyText}>Try a different search term</Text>
</View>
) : projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🏺</Text>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Start your first piece!</Text>
</View>
) : (
<FlatList
data={filteredProjects}
renderItem={renderProject}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
ListHeaderComponent={
<View>
{/* MY PROJECTS Title */}
<View style={styles.header}>
<Text style={styles.headerTitle}>My Projects</Text>
</View>
{/* Create Project Button */}
<View style={styles.buttonContainer}>
<Button
title="New Project"
onPress={handleCreateProject}
size="sm"
accessibilityLabel="Create new project"
/>
</View>
{/* Search Bar */}
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Search projects or tags..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor={colors.textSecondary}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')} style={styles.clearButton}>
<Text style={styles.clearButtonText}></Text>
</TouchableOpacity>
)}
</View>
{/* Sort Buttons */}
<View style={styles.sortContainer}>
<Text style={styles.sortLabel}>Sort:</Text>
<TouchableOpacity
style={[styles.sortButton, sortBy === 'newest' && styles.sortButtonActive]}
onPress={() => setSortBy('newest')}
>
<Text style={[styles.sortButtonText, sortBy === 'newest' && styles.sortButtonTextActive]}>
Newest
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sortButton, sortBy === 'oldest' && styles.sortButtonActive]}
onPress={() => setSortBy('oldest')}
>
<Text style={[styles.sortButtonText, sortBy === 'oldest' && styles.sortButtonTextActive]}>
Oldest
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sortButton, sortBy === 'name' && styles.sortButtonActive]}
onPress={() => setSortBy('name')}
>
<Text style={[styles.sortButtonText, sortBy === 'name' && styles.sortButtonTextActive]}>
Name
</Text>
</TouchableOpacity>
</View>
</View>
}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: 64,
paddingBottom: spacing.lg,
backgroundColor: colors.background,
borderBottomWidth: 3,
borderBottomColor: colors.primary,
},
headerTitle: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
searchInput: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: typography.fontSize.md,
color: colors.text,
fontWeight: typography.fontWeight.medium,
},
clearButton: {
position: 'absolute',
right: spacing.lg + spacing.md,
paddingHorizontal: spacing.sm,
},
clearButtonText: {
fontSize: typography.fontSize.lg,
color: colors.textSecondary,
fontWeight: typography.fontWeight.bold,
},
sortContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
gap: spacing.sm,
},
sortLabel: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
sortButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
},
sortButtonActive: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
sortButtonText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.3,
},
sortButtonTextActive: {
color: colors.text,
},
listContent: {
paddingHorizontal: spacing.md,
paddingBottom: 100, // Extra space for floating tab bar
},
projectCard: {
marginBottom: spacing.md,
},
imageContainer: {
position: 'relative',
marginBottom: spacing.md,
},
coverImage: {
width: '100%',
height: 200,
borderRadius: borderRadius.md,
backgroundColor: colors.backgroundSecondary,
borderWidth: 2,
borderColor: colors.border,
},
stepCountBadge: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
backgroundColor: colors.primary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.primaryDark,
},
stepCountText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.background,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
projectInfo: {
gap: spacing.sm,
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
projectTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.3,
flex: 1,
},
projectMeta: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: borderRadius.full,
borderWidth: 2,
},
statusBadgeInProgress: {
backgroundColor: '#FFF4ED',
borderColor: '#E8A87C',
},
statusBadgeDone: {
backgroundColor: '#E8F9F8',
borderColor: '#85CDCA',
},
statusBadgeArchived: {
backgroundColor: '#F9EFF3',
borderColor: '#C38D9E',
},
statusText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
date: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
},
tag: {
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.primaryDark,
},
tagText: {
fontSize: typography.fontSize.xs,
color: colors.background,
fontWeight: typography.fontWeight.bold,
letterSpacing: 0.5,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
emptyIcon: {
fontSize: 64,
marginBottom: spacing.md,
},
emptyTitle: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.semiBold,
color: colors.text,
marginBottom: spacing.sm,
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.lg,
},
emptyButton: {
marginTop: spacing.md,
},
});

View File

@@ -0,0 +1,290 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, Switch, Alert, Share } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { getSettings, updateSettings, getAllProjects, getStepsByProject } from '../lib/db/repositories';
import { Settings } from '../types';
import { Card, Button } from '../components';
import { colors, spacing, typography } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
export const SettingsScreen: React.FC = () => {
const { user, signOut } = useAuth();
const [settings, setSettings] = useState<Settings>({
unitSystem: 'imperial',
tempUnit: 'F',
analyticsOptIn: false,
});
useEffect(() => {
if (user) {
loadSettings();
}
}, [user]);
const loadSettings = async () => {
if (!user) return;
try {
const current = await getSettings(user.id);
console.log('Loaded settings:', current);
setSettings(current);
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const handleToggle = async (key: keyof Settings, value: any) => {
if (!user) return;
try {
const updated = { ...settings, [key]: value };
console.log('Updating settings:', key, '=', value, 'Full settings:', updated);
setSettings(updated);
await updateSettings(user.id, updated);
console.log('Settings saved successfully');
} catch (error) {
console.error('Failed to save settings:', error);
Alert.alert('Error', 'Failed to save settings');
}
};
const handleExportData = async () => {
if (!user) return;
try {
const projects = await getAllProjects(user.id);
const exportData: any = {
exportDate: new Date().toISOString(),
version: '1.0.0',
projects: [],
};
for (const project of projects) {
const steps = await getStepsByProject(project.id);
exportData.projects.push({
...project,
steps,
});
}
const jsonString = JSON.stringify(exportData, null, 2);
// Share the JSON data
await Share.share({
message: jsonString,
title: 'Pottery Diary Export',
});
Alert.alert('Success', 'Data exported successfully');
} catch (error) {
console.error('Export failed:', error);
Alert.alert('Error', 'Failed to export data');
}
};
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
await signOut();
} catch (error) {
console.error('Logout failed:', error);
Alert.alert('Error', 'Failed to logout');
}
},
},
]
);
};
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Card>
<Text style={styles.sectionTitle}>Units</Text>
<View style={styles.setting}>
<View>
<Text style={styles.settingLabel}>Temperature Unit</Text>
<Text style={styles.settingValue}>
{settings.tempUnit === 'F' ? 'Fahrenheit (°F)' : 'Celsius (°C)'}
</Text>
</View>
<Switch
value={settings.tempUnit === 'C'}
onValueChange={(value) => handleToggle('tempUnit', value ? 'C' : 'F')}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={settings.tempUnit === 'C' ? colors.background : colors.background}
ios_backgroundColor={colors.border}
/>
</View>
<View style={styles.setting}>
<View>
<Text style={styles.settingLabel}>Unit System</Text>
<Text style={styles.settingValue}>
{settings.unitSystem === 'imperial' ? 'Imperial (lb/in)' : 'Metric (kg/cm)'}
</Text>
</View>
<Switch
value={settings.unitSystem === 'metric'}
onValueChange={(value) =>
handleToggle('unitSystem', value ? 'metric' : 'imperial')
}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={settings.unitSystem === 'metric' ? colors.background : colors.background}
ios_backgroundColor={colors.border}
/>
</View>
</Card>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>Data</Text>
<Button
title="Export All Data (JSON)"
onPress={handleExportData}
variant="outline"
/>
<Text style={styles.exportNote}>
Export all your projects and steps as JSON for backup or sharing
</Text>
</Card>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>Account</Text>
{user && (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Logged in as</Text>
<Text style={styles.infoValue}>{user.name}</Text>
</View>
)}
{user && (
<View style={[styles.infoRow, styles.noBorder]}>
<Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{user.email}</Text>
</View>
)}
<Button
title="Logout"
onPress={handleLogout}
variant="outline"
style={styles.logoutButton}
/>
</Card>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Version</Text>
<Text style={styles.infoValue}>1.0.0</Text>
</View>
<View style={[styles.infoRow, styles.noBorder]}>
<Text style={styles.infoLabel}>Developer</Text>
<Text style={styles.infoValue}>Made with for makers</Text>
</View>
</Card>
<View style={styles.footer}>
<Text style={styles.footerText}>🏺 Pottery Diary</Text>
<Text style={styles.footerText}>Keep track of your ceramic journey</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
container: {
flex: 1,
},
content: {
paddingHorizontal: spacing.md,
paddingTop: spacing.xl,
paddingBottom: 100, // Extra space for floating tab bar
},
card: {
marginTop: spacing.md,
},
sectionTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.md,
letterSpacing: 0.5,
},
setting: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
settingLabel: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.medium,
color: colors.text,
},
settingValue: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
infoLabel: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.3,
},
infoValue: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingVertical: spacing.lg,
},
footerText: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xs,
},
exportNote: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.md,
fontStyle: 'italic',
},
logoutButton: {
marginTop: spacing.md,
},
noBorder: {
borderBottomWidth: 0,
},
});

View File

@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Alert,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { Button, Input } from '../components';
import { colors, spacing, typography } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'SignUp'>;
export const SignUpScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { signUp } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSignUp = async () => {
// Validation
if (!name.trim() || !email.trim() || !password.trim() || !confirmPassword.trim()) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match');
return;
}
if (password.length < 6) {
Alert.alert('Error', 'Password must be at least 6 characters');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
Alert.alert('Error', 'Please enter a valid email address');
return;
}
setLoading(true);
try {
await signUp(email.trim(), password, name.trim());
// Navigation will be handled automatically by auth state change
} catch (error) {
Alert.alert('Sign Up Failed', error instanceof Error ? error.message : 'Could not create account');
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.logo}>🏺</Text>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Start your pottery journey</Text>
</View>
<View style={styles.form}>
<Input
label="Name"
value={name}
onChangeText={setName}
placeholder="Your name"
autoCapitalize="words"
autoComplete="name"
/>
<Input
label="Email"
value={email}
onChangeText={setEmail}
placeholder="your@email.com"
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
value={password}
onChangeText={setPassword}
placeholder="At least 6 characters"
secureTextEntry
autoCapitalize="none"
autoComplete="password-new"
/>
<Input
label="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Re-enter password"
secureTextEntry
autoCapitalize="none"
autoComplete="password-new"
/>
<Button
title="Sign Up"
onPress={handleSignUp}
loading={loading}
style={styles.signUpButton}
/>
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.loginLink}>Login</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.xl,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xl * 2,
},
logo: {
fontSize: 80,
marginBottom: spacing.md,
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
marginBottom: spacing.xs,
letterSpacing: 1,
},
subtitle: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
letterSpacing: 0.5,
},
form: {
width: '100%',
},
signUpButton: {
marginTop: spacing.md,
},
loginContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing.lg,
},
loginText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
},
loginLink: {
fontSize: typography.fontSize.md,
color: colors.primary,
fontWeight: typography.fontWeight.bold,
},
});

View File

@@ -0,0 +1,709 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Alert, Image, TouchableOpacity, FlatList } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import * as ImagePicker from 'expo-image-picker';
import { useNavigation, useRoute, RouteProp, CommonActions } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { StepType } from '../types';
import { createStep, getStep, updateStep, deleteStep, getSettings } from '../lib/db/repositories';
import { getConeTemperature, getBisqueCones, getGlazeFiringCones } from '../lib/utils';
import { Button, Input, Card } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'StepEditor'>;
type RouteProps = RouteProp<RootStackParamList, 'StepEditor'>;
const STEP_TYPES: { value: StepType; label: string }[] = [
{ value: 'forming', label: '🏺 Forming' },
{ value: 'trimming', label: '🔧 Trimming' },
{ value: 'drying', label: '☀️ Drying' },
{ value: 'bisque_firing', label: '🔥 Bisque Firing' },
{ value: 'glazing', label: '🎨 Glazing' },
{ value: 'glaze_firing', label: '⚡ Glaze Firing' },
{ value: 'misc', label: '📝 Misc' },
];
export const StepEditorScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteProps>();
const { user } = useAuth();
const { projectId, stepId } = route.params;
const isEditing = !!stepId;
// Create unique editor key for this screen instance
const editorKey = React.useRef(route.params._editorKey || `editor-${Date.now()}`).current;
const [stepType, setStepType] = useState<StepType>('forming');
const [notes, setNotes] = useState('');
const [photos, setPhotos] = useState<string[]>([]);
const [cone, setCone] = useState('');
const [temperature, setTemperature] = useState('');
const [tempUnit, setTempUnit] = useState<'F' | 'C'>('F'); // User's preferred unit
const [duration, setDuration] = useState('');
const [kilnNotes, setKilnNotes] = useState('');
const [coats, setCoats] = useState('2');
const [applicationMethod, setApplicationMethod] = useState<'brush' | 'dip' | 'spray' | 'pour' | 'other'>('brush');
const [selectedGlazeIds, setSelectedGlazeIds] = useState<string[]>([]);
const [mixNotes, setMixNotes] = useState('');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(isEditing);
// Load user's temperature preference
useEffect(() => {
loadUserTempPreference();
}, [user]);
const loadUserTempPreference = async () => {
if (!user) return;
try {
const settings = await getSettings(user.id);
setTempUnit(settings.tempUnit);
} catch (error) {
console.error('Failed to load temp preference:', error);
}
};
useEffect(() => {
if (isEditing && stepId) {
loadStep();
}
}, [stepId]);
// Listen for glaze selection from GlazePicker
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
// Check if we have glaze selection in route params
if (route.params && 'selectedGlazeIds' in route.params && route.params.selectedGlazeIds) {
console.log('Received selected glaze IDs:', route.params.selectedGlazeIds);
setSelectedGlazeIds(route.params.selectedGlazeIds);
}
});
return unsubscribe;
}, [navigation]);
// Also watch for route param changes directly and auto-save
useEffect(() => {
let hasChanges = false;
if (route.params && 'selectedGlazeIds' in route.params && route.params.selectedGlazeIds) {
console.log('Route params changed - selected glaze IDs:', route.params.selectedGlazeIds);
setSelectedGlazeIds(route.params.selectedGlazeIds);
// IMPORTANT: Automatically set stepType to 'glazing' when glazes are selected
if (stepType !== 'glazing') {
console.log('Auto-setting stepType to glazing');
setStepType('glazing');
}
hasChanges = true;
}
if (route.params && 'mixNotes' in route.params && route.params.mixNotes) {
console.log('Route params changed - mix notes:', route.params.mixNotes);
setMixNotes(route.params.mixNotes);
hasChanges = true;
}
// Auto-save after state updates
if (hasChanges) {
console.log('Auto-save triggered - stepType:', stepType, 'isEditing:', isEditing, 'stepId:', stepId);
// Use setTimeout to wait for state updates to complete
setTimeout(async () => {
try {
// Always use 'glazing' type when we have glaze IDs
const finalStepType = route.params?.selectedGlazeIds ? 'glazing' : stepType;
const stepData: any = {
projectId,
type: finalStepType,
notesMarkdown: notes,
photoUris: photos,
};
// Add glazing data if we have glaze selections
if (route.params?.selectedGlazeIds) {
stepData.glazing = {
glazeIds: route.params.selectedGlazeIds,
coats: coats ? parseInt(coats) : undefined,
application: applicationMethod,
mixNotes: route.params.mixNotes || undefined,
};
console.log('Glazing data prepared:', stepData.glazing);
}
if (isEditing && stepId) {
console.log('Updating existing step:', stepId);
await updateStep(stepId, stepData);
console.log('✅ Auto-saved step with glazes');
// Reset navigation to ProjectDetail, keeping MainTabs in stack
console.log('Resetting stack to MainTabs → ProjectDetail');
navigation.dispatch(
CommonActions.reset({
index: 1, // ProjectDetail is the active screen
routes: [
{ name: 'MainTabs' }, // Keep MainTabs in stack for back button
{ name: 'ProjectDetail', params: { projectId } }, // ProjectDetail is active
],
})
);
} else if (!isEditing) {
// If creating a new step, create it first
console.log('Creating new step with type:', finalStepType);
const newStep = await createStep(stepData);
if (newStep) {
console.log('✅ Auto-created step with glazes, new stepId:', newStep.id);
// Reset navigation to ProjectDetail, keeping MainTabs in stack
console.log('Resetting stack to MainTabs → ProjectDetail');
navigation.dispatch(
CommonActions.reset({
index: 1, // ProjectDetail is the active screen
routes: [
{ name: 'MainTabs' }, // Keep MainTabs in stack for back button
{ name: 'ProjectDetail', params: { projectId } }, // ProjectDetail is active
],
})
);
}
}
} catch (error) {
console.error('❌ Auto-save failed:', error);
}
}, 100);
}
}, [route.params?.selectedGlazeIds, route.params?.mixNotes]);
// Auto-save whenever any field changes (but only for existing steps)
useEffect(() => {
// Don't auto-save on initial load or if we're still loading
if (loading) return;
// Only auto-save for existing steps that have been edited
if (isEditing && stepId) {
console.log('Field changed - triggering auto-save');
// Debounce the save by 1 second to avoid saving on every keystroke
const timeoutId = setTimeout(async () => {
try {
const stepData: any = {
projectId,
type: stepType,
notesMarkdown: notes,
photoUris: photos,
};
if (stepType === 'bisque_firing' || stepType === 'glaze_firing') {
stepData.firing = {
cone: cone || undefined,
temperature: temperature ? { value: parseInt(temperature), unit: tempUnit } : undefined,
durationMinutes: duration ? parseInt(duration) : undefined,
kilnNotes: kilnNotes || undefined,
};
} else if (stepType === 'glazing') {
stepData.glazing = {
glazeIds: selectedGlazeIds,
coats: coats ? parseInt(coats) : undefined,
application: applicationMethod,
mixNotes: mixNotes || undefined,
};
}
await updateStep(stepId, stepData);
console.log('⚡ Auto-saved changes');
} catch (error) {
console.error('Auto-save error:', error);
}
}, 1000); // Wait 1 second after last change
return () => clearTimeout(timeoutId);
}
}, [
stepType,
notes,
photos,
cone,
temperature,
duration,
kilnNotes,
coats,
applicationMethod,
selectedGlazeIds,
mixNotes,
isEditing,
stepId,
loading,
]);
const loadStep = async () => {
try {
const step = await getStep(stepId!);
if (!step) {
Alert.alert('Error', 'Step not found');
navigation.goBack();
return;
}
setStepType(step.type);
setNotes(step.notesMarkdown || '');
setPhotos(step.photoUris || []);
if (step.type === 'bisque_firing' || step.type === 'glaze_firing') {
const firing = (step as any).firing;
if (firing) {
setCone(firing.cone || '');
setTemperature(firing.temperature?.value?.toString() || '');
setDuration(firing.durationMinutes?.toString() || '');
setKilnNotes(firing.kilnNotes || '');
}
} else if (step.type === 'glazing') {
const glazing = (step as any).glazing;
if (glazing) {
setCoats(glazing.coats?.toString() || '2');
setApplicationMethod(glazing.application || 'brush');
setSelectedGlazeIds(glazing.glazeIds || []);
setMixNotes(glazing.mixNotes || '');
}
}
} catch (error) {
console.error('Failed to load step:', error);
Alert.alert('Error', 'Failed to load step');
} finally {
setLoading(false);
}
};
const handleConeChange = (value: string) => {
setCone(value);
const temp = getConeTemperature(value, tempUnit); // Pass user's preferred unit
if (temp) {
setTemperature(temp.value.toString());
}
};
const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'We need camera permissions to take photos');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setPhotos([...photos, result.assets[0].uri]);
}
};
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'We need camera roll permissions to add photos');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setPhotos([...photos, result.assets[0].uri]);
}
};
const handleAddPhoto = () => {
Alert.alert(
'Add Photo',
'Choose a source',
[
{ text: 'Camera', onPress: takePhoto },
{ text: 'Gallery', onPress: pickImage },
{ text: 'Cancel', style: 'cancel' },
]
);
};
const removePhoto = (index: number) => {
setPhotos(photos.filter((_, i) => i !== index));
};
const handleSave = async () => {
setSaving(true);
try {
const stepData: any = {
projectId,
type: stepType,
notesMarkdown: notes,
photoUris: photos,
};
if (stepType === 'bisque_firing' || stepType === 'glaze_firing') {
stepData.firing = {
cone: cone || undefined,
temperature: temperature ? { value: parseInt(temperature), unit: tempUnit } : undefined,
durationMinutes: duration ? parseInt(duration) : undefined,
kilnNotes: kilnNotes || undefined,
};
} else if (stepType === 'glazing') {
stepData.glazing = {
glazeIds: selectedGlazeIds,
coats: coats ? parseInt(coats) : undefined,
application: applicationMethod,
mixNotes: mixNotes || undefined,
};
}
if (isEditing) {
await updateStep(stepId!, stepData);
console.log('✅ Step updated successfully');
} else {
const newStep = await createStep(stepData);
console.log('✅ Step created successfully, id:', newStep?.id);
}
// Reset navigation to ProjectDetail, keeping MainTabs in stack
console.log('Resetting stack to MainTabs → ProjectDetail after manual save');
navigation.dispatch(
CommonActions.reset({
index: 1, // ProjectDetail is the active screen
routes: [
{ name: 'MainTabs' }, // Keep MainTabs in stack for back button
{ name: 'ProjectDetail', params: { projectId } }, // ProjectDetail is active
],
})
);
} catch (error) {
console.error('Failed to save step:', error);
Alert.alert('Error', 'Failed to save step');
} finally {
setSaving(false);
}
};
const handleDelete = () => {
Alert.alert(
'Delete Step',
'Are you sure you want to delete this step?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deleteStep(stepId!);
Alert.alert('Success', 'Step deleted');
navigation.goBack();
} catch (error) {
console.error('Failed to delete step:', error);
Alert.alert('Error', 'Failed to delete step');
}
},
},
]
);
};
const renderFiringFields = () => (
<>
<Input
label="Cone"
value={cone}
onChangeText={handleConeChange}
placeholder="e.g., 04, 6, 10"
/>
<Input
label={`Temperature (°${tempUnit})`}
value={temperature}
onChangeText={setTemperature}
placeholder="Auto-filled from cone"
keyboardType="numeric"
/>
<Input
label="Duration (minutes)"
value={duration}
onChangeText={setDuration}
placeholder="Total firing time"
keyboardType="numeric"
/>
<Input
label="Kiln Notes"
value={kilnNotes}
onChangeText={setKilnNotes}
placeholder="Kiln type, ramp, etc."
multiline
/>
</>
);
const renderGlazingFields = () => {
const applicationMethods = [
{ value: 'brush', label: '🖌️ Brush', icon: '🖌️' },
{ value: 'dip', label: '💧 Dip', icon: '💧' },
{ value: 'spray', label: '💨 Spray', icon: '💨' },
{ value: 'pour', label: '🫗 Pour', icon: '🫗' },
{ value: 'other', label: '✨ Other', icon: '✨' },
];
return (
<>
<Input
label="Number of Coats"
value={coats}
onChangeText={setCoats}
placeholder="2"
keyboardType="numeric"
/>
<Text style={styles.label}>Application Method</Text>
<View style={styles.applicationGrid}>
{applicationMethods.map((method) => (
<TouchableOpacity
key={method.value}
style={[
styles.applicationButton,
applicationMethod === method.value && styles.applicationButtonActive,
]}
onPress={() => setApplicationMethod(method.value as any)}
>
<Text style={styles.applicationIcon}>{method.icon}</Text>
<Text style={[
styles.applicationText,
applicationMethod === method.value && styles.applicationTextActive,
]}>
{method.value.charAt(0).toUpperCase() + method.value.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<Button
title={`Select Glazes (${selectedGlazeIds.length} selected)`}
onPress={() => navigation.navigate('GlazePicker', {
projectId,
stepId,
selectedGlazeIds,
_editorKey: editorKey,
})}
variant="outline"
style={styles.glazePickerButton}
/>
<Text style={styles.note}>
{selectedGlazeIds.length === 0
? 'Tap above to select glazes from catalog'
: `${selectedGlazeIds.length} glaze${selectedGlazeIds.length > 1 ? 's' : ''} selected`}
</Text>
</>
);
};
return (
<KeyboardAwareScrollView
style={styles.container}
contentContainerStyle={styles.content}
enableOnAndroid={true}
enableAutomaticScroll={true}
extraScrollHeight={100}
keyboardShouldPersistTaps="handled"
>
<Card>
<Text style={styles.label}>Step Type</Text>
<View style={styles.typeGrid}>
{STEP_TYPES.map((type) => (
<Button
key={type.value}
title={type.label}
onPress={() => setStepType(type.value)}
variant={stepType === type.value ? 'primary' : 'outline'}
size="sm"
style={styles.typeButton}
/>
))}
</View>
{(stepType === 'bisque_firing' || stepType === 'glaze_firing') && renderFiringFields()}
{stepType === 'glazing' && renderGlazingFields()}
<Text style={styles.label}>Photos</Text>
<View style={styles.photosContainer}>
{photos.map((uri, index) => (
<View key={index} style={styles.photoWrapper}>
<Image source={{ uri }} style={styles.photo} />
<TouchableOpacity
style={styles.removePhotoButton}
onPress={() => removePhoto(index)}
>
<Text style={styles.removePhotoText}></Text>
</TouchableOpacity>
</View>
))}
<TouchableOpacity style={styles.addPhotoButton} onPress={handleAddPhoto}>
<Text style={styles.addPhotoIcon}>📷</Text>
<Text style={styles.addPhotoText}>Add Photo</Text>
</TouchableOpacity>
</View>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Add notes about this step"
multiline
numberOfLines={4}
/>
<Button
title={isEditing ? "Update Step" : "Save Step"}
onPress={handleSave}
loading={saving}
/>
{isEditing && (
<Button
title="Delete Step"
onPress={handleDelete}
variant="outline"
style={styles.deleteButton}
/>
)}
</Card>
</KeyboardAwareScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
content: {
padding: spacing.md,
paddingBottom: spacing.xxl,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.sm,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
typeGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.lg,
},
typeButton: {
minWidth: '45%',
},
note: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
fontStyle: 'italic',
marginTop: spacing.sm,
},
photosContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.lg,
},
photoWrapper: {
position: 'relative',
width: 100,
height: 100,
},
photo: {
width: '100%',
height: '100%',
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
},
removePhotoButton: {
position: 'absolute',
top: -8,
right: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.error,
justifyContent: 'center',
alignItems: 'center',
},
removePhotoText: {
color: colors.background,
fontSize: 14,
fontWeight: 'bold',
},
addPhotoButton: {
width: 100,
height: 100,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.backgroundSecondary,
justifyContent: 'center',
alignItems: 'center',
},
addPhotoIcon: {
fontSize: 32,
marginBottom: spacing.xs,
},
addPhotoText: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
fontWeight: typography.fontWeight.bold,
},
deleteButton: {
marginTop: spacing.md,
},
applicationGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.lg,
},
applicationButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
gap: spacing.xs,
},
applicationButtonActive: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
applicationIcon: {
fontSize: 18,
},
applicationText: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
},
applicationTextActive: {
color: colors.text,
},
glazePickerButton: {
marginBottom: spacing.sm,
},
});

10
src/screens/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from './OnboardingScreen';
export * from './LoginScreen';
export * from './SignUpScreen';
export * from './ProjectsScreen';
export * from './ProjectDetailScreen';
export * from './StepEditorScreen';
export * from './NewsScreen';
export * from './SettingsScreen';
export * from './GlazePickerScreen';
export * from './GlazeMixerScreen';

104
src/types/index.ts Normal file
View File

@@ -0,0 +1,104 @@
export type UUID = string;
export type ProjectStatus = 'in_progress' | 'done' | 'archived';
export interface Project {
id: UUID;
title: string;
status: ProjectStatus;
tags: string[];
coverImageUri?: string;
createdAt: string;
updatedAt: string;
}
export type StepType = 'forming' | 'trimming' | 'drying' | 'bisque_firing' | 'glazing' | 'glaze_firing' | 'misc';
export type TemperatureUnit = 'F' | 'C';
export interface Temperature {
value: number;
unit: TemperatureUnit;
}
export interface FiringFields {
cone?: string; // e.g., '04', '6', '10', supports '06'
temperature?: Temperature;
durationMinutes?: number;
kilnNotes?: string;
}
export type ApplicationMethod = 'brush' | 'dip' | 'spray' | 'pour' | 'other';
export interface GlazingFields {
glazeIds: UUID[]; // references to Glaze (or custom)
coats?: number; // layers
application?: ApplicationMethod;
mixNotes?: string; // notes about glaze mixing ratios (e.g., "50/50", "3:1")
}
export interface StepBase {
id: UUID;
projectId: UUID;
type: StepType;
notesMarkdown?: string;
photoUris: string[]; // local URIs
createdAt: string;
updatedAt: string;
}
export type Step =
| (StepBase & { type: 'bisque_firing'; firing: FiringFields })
| (StepBase & { type: 'glaze_firing'; firing: FiringFields })
| (StepBase & { type: 'glazing'; glazing: GlazingFields })
| (StepBase & { type: 'forming' | 'trimming' | 'drying' | 'misc' });
export type GlazeFinish = 'glossy' | 'satin' | 'matte' | 'special' | 'unknown';
export interface Glaze {
id: UUID;
brand: string; // free text; seed list available
name: string;
code?: string; // brand code
color?: string; // hex color code for preview
finish?: GlazeFinish;
notes?: string;
isCustom: boolean; // true if user-entered
isMix?: boolean; // true if this is a mixed glaze
mixedGlazeIds?: UUID[]; // IDs of glazes that were mixed
mixRatio?: string; // e.g. "50/50", "3:1", "Equal parts"
}
export interface NewsItem {
id: UUID;
title: string;
excerpt?: string;
url?: string;
contentHtml?: string;
publishedAt: string;
}
export type UnitSystem = 'imperial' | 'metric';
export interface Settings {
unitSystem: UnitSystem;
tempUnit: TemperatureUnit;
analyticsOptIn: boolean;
}
// Helper type guards
export function isBisqueFiring(step: Step): step is StepBase & { type: 'bisque_firing'; firing: FiringFields } {
return step.type === 'bisque_firing';
}
export function isGlazeFiring(step: Step): step is StepBase & { type: 'glaze_firing'; firing: FiringFields } {
return step.type === 'glaze_firing';
}
export function isGlazing(step: Step): step is StepBase & { type: 'glazing'; glazing: GlazingFields } {
return step.type === 'glazing';
}
export function isFiringStep(step: Step): step is StepBase & { type: 'bisque_firing' | 'glaze_firing'; firing: FiringFields } {
return step.type === 'bisque_firing' || step.type === 'glaze_firing';
}