ueberpruefen
This commit is contained in:
150
src/components/Button.tsx
Normal file
150
src/components/Button.tsx
Normal 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
27
src/components/Card.tsx
Normal 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
77
src/components/Input.tsx
Normal 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
3
src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Button';
|
||||
export * from './Card';
|
||||
export * from './Input';
|
||||
173
src/contexts/AuthContext.tsx
Normal file
173
src/contexts/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
72
src/lib/analytics/index.ts
Normal file
72
src/lib/analytics/index.ts
Normal 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
194
src/lib/db/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
250
src/lib/db/repositories/glazeRepository.ts
Normal file
250
src/lib/db/repositories/glazeRepository.ts
Normal 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;
|
||||
}
|
||||
5
src/lib/db/repositories/index.ts
Normal file
5
src/lib/db/repositories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './projectRepository';
|
||||
export * from './stepRepository';
|
||||
export * from './glazeRepository';
|
||||
export * from './settingsRepository';
|
||||
export * from './newsRepository';
|
||||
54
src/lib/db/repositories/newsRepository.ts
Normal file
54
src/lib/db/repositories/newsRepository.ts
Normal 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()]
|
||||
);
|
||||
}
|
||||
130
src/lib/db/repositories/projectRepository.ts
Normal file
130
src/lib/db/repositories/projectRepository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
51
src/lib/db/repositories/settingsRepository.ts
Normal file
51
src/lib/db/repositories/settingsRepository.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
229
src/lib/db/repositories/stepRepository.ts
Normal file
229
src/lib/db/repositories/stepRepository.ts
Normal 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
111
src/lib/db/schema.ts
Normal 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
138
src/lib/theme/index.ts
Normal 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',
|
||||
};
|
||||
108
src/lib/utils/colorMixing.ts
Normal file
108
src/lib/utils/colorMixing.ts
Normal 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`;
|
||||
}
|
||||
137
src/lib/utils/coneConverter.ts
Normal file
137
src/lib/utils/coneConverter.ts
Normal 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'];
|
||||
}
|
||||
65
src/lib/utils/conversions.ts
Normal file
65
src/lib/utils/conversions.ts
Normal 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
76
src/lib/utils/datetime.ts
Normal 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
6
src/lib/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './conversions';
|
||||
export * from './coneConverter';
|
||||
export * from './uuid';
|
||||
export * from './datetime';
|
||||
export * from './stepOrdering';
|
||||
export * from './colorMixing';
|
||||
93
src/lib/utils/stepOrdering.ts
Normal file
93
src/lib/utils/stepOrdering.ts
Normal 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
10
src/lib/utils/uuid.ts
Normal 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
246
src/navigation/index.tsx
Normal 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
26
src/navigation/types.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
365
src/screens/GlazeMixerScreen.tsx
Normal file
365
src/screens/GlazeMixerScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
311
src/screens/GlazePickerScreen.tsx
Normal file
311
src/screens/GlazePickerScreen.tsx
Normal 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
153
src/screens/LoginScreen.tsx
Normal 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
234
src/screens/NewsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
141
src/screens/OnboardingScreen.tsx
Normal file
141
src/screens/OnboardingScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
599
src/screens/ProjectDetailScreen.tsx
Normal file
599
src/screens/ProjectDetailScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
490
src/screens/ProjectsScreen.tsx
Normal file
490
src/screens/ProjectsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
290
src/screens/SettingsScreen.tsx
Normal file
290
src/screens/SettingsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
196
src/screens/SignUpScreen.tsx
Normal file
196
src/screens/SignUpScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
709
src/screens/StepEditorScreen.tsx
Normal file
709
src/screens/StepEditorScreen.tsx
Normal 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
10
src/screens/index.ts
Normal 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
104
src/types/index.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user