Farb palette

This commit is contained in:
Timo Knuth 2025-12-04 16:06:58 +01:00
parent 0554fdda01
commit aa9bd3f0b6
50 changed files with 3971 additions and 899 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"WebFetch(domain:code.claude.com)",
"Read(//home/tknuth/.claude/**)"
],
"deny": [],
"ask": []
}
}

View File

@ -0,0 +1,136 @@
---
name: shadcn-ui
description: Create beautiful, accessible React Native components following shadcn/ui design principles. Use for building UI components with consistent styling, proper TypeScript types, and accessibility features.
---
# shadcn/ui Style Components for React Native
This skill helps create React Native components following shadcn/ui design principles:
## Core Principles
1. **Component Philosophy**
- Components are copied into the project (not npm installed)
- Full ownership and customization
- TypeScript-first with proper type definitions
- Composable and reusable
2. **Design Tokens**
- Use the project's theme system from `src/lib/theme/index.ts`
- Consistent spacing, colors, and typography
- Support light/dark mode if configured
3. **Component Structure**
```typescript
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { theme } from '@/lib/theme';
export interface ComponentProps {
variant?: 'default' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
// Add prop types
}
export function Component({ variant = 'default', size = 'md', ...props }: ComponentProps) {
return (
<View style={[styles.base, styles[variant], styles[size]]}>
{/* Component content */}
</View>
);
}
const styles = StyleSheet.create({
base: {
// Base styles
},
// Variant styles
default: {},
outline: {},
ghost: {},
// Size styles
sm: {},
md: {},
lg: {},
});
```
4. **Accessibility**
- Always include `accessibilityLabel`
- Use `accessibilityRole` appropriately
- Support `accessibilityHint` when helpful
- Test with screen readers
5. **Common Patterns**
**Button Component:**
- Variants: default, outline, ghost, destructive
- Sizes: sm, md, lg
- States: disabled, loading
- Supports press feedback
**Card Component:**
- Header, Content, Footer sections
- Optional borders and shadows
- Consistent padding
**Input Component:**
- Label and error message support
- Focus states
- Validation feedback
**Badge Component:**
- Variants: default, success, warning, error
- Compact sizing
6. **File Organization**
- Place components in `src/components/`
- Export from `src/components/index.ts`
- Keep component + styles in same file
- Create compound components when needed
7. **Styling Approach**
- Use StyleSheet.create for performance
- Leverage theme tokens
- Support style prop for overrides
- Use flexbox for layouts
## Examples
### Button with Variants
```typescript
<Button variant="outline" size="lg" onPress={handlePress}>
Click me
</Button>
```
### Card Composition
```typescript
<Card>
<Card.Header>
<Card.Title>Title</Card.Title>
</Card.Header>
<Card.Content>
Content here
</Card.Content>
</Card>
```
### Form Input
```typescript
<Input
label="Email"
value={email}
onChangeText={setEmail}
error={errors.email}
accessibilityLabel="Email input"
/>
```
## When to Use This Skill
- Creating new UI components
- Refactoring existing components to be more consistent
- Adding variants or sizes to components
- Improving component accessibility
- Establishing a design system

View File

@ -0,0 +1,299 @@
---
name: ux-seo-aeo
description: Design exceptional user experiences optimized for Top Tier SEO and AEO (Answer Engine Optimization). Use when creating content, structuring data, optimizing performance, or improving discoverability for search engines and AI assistants.
---
# UX/SEO/AEO Optimization Skill
Expert guidance for creating user-centric experiences optimized for modern search engines and AI-powered answer engines.
## Core Principles
### 1. User Experience First (UX)
- **User-Centered Design**: Every decision prioritizes user needs
- **Accessibility (WCAG 2.1 AA+)**: Inclusive design for all users
- **Performance**: Fast load times (<3s), smooth interactions
- **Mobile-First**: Responsive, touch-friendly, thumb-reachable
- **Clear Hierarchy**: Logical information architecture
- **Feedback**: Clear loading states, errors, success messages
### 2. Search Engine Optimization (SEO)
**Technical SEO:**
- Semantic HTML structure (`<header>`, `<nav>`, `<main>`, `<article>`, `<section>`)
- Proper heading hierarchy (single H1, logical H2-H6)
- Fast Core Web Vitals (LCP, FID, CLS)
- Mobile-friendly and responsive
- HTTPS and secure connections
- XML sitemap and robots.txt
- Canonical URLs to prevent duplicates
**On-Page SEO:**
- Unique, descriptive titles (50-60 chars)
- Compelling meta descriptions (150-160 chars)
- Descriptive, keyword-rich URLs
- Alt text for all images (descriptive, not keyword stuffing)
- Internal linking with descriptive anchor text
- Schema.org structured data (JSON-LD)
**Content SEO:**
- High-quality, original, valuable content
- Answer user intent explicitly
- Natural keyword integration
- Comprehensive topic coverage
- Regular content updates
- E-E-A-T: Experience, Expertise, Authoritativeness, Trust
### 3. Answer Engine Optimization (AEO)
**Optimize for AI/LLMs:**
- Clear, concise answers to questions
- Featured snippet optimization
- FAQ sections with schema markup
- Conversational, natural language
- Entity-based content structure
- Topic clusters and pillar pages
**Structured Data (Schema.org):**
```json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Clear, descriptive headline",
"author": {
"@type": "Person",
"name": "Author Name"
},
"datePublished": "2024-01-01",
"image": "https://example.com/image.jpg",
"description": "Brief, accurate description"
}
```
**Question-Answer Format:**
- Use clear question headings (H2/H3)
- Provide direct answers in first paragraph
- Expand with details and context
- Use FAQ schema for multiple Q&As
**Knowledge Graph Optimization:**
- Consistent NAP (Name, Address, Phone)
- Linked social profiles
- Entity relationships clearly defined
- Breadcrumb navigation
## Implementation Checklist
### Content Structure
- [ ] Single, clear H1 with primary keyword
- [ ] Logical heading hierarchy (H2-H6)
- [ ] Introduction answers "what/why" immediately
- [ ] Scannable content (bullets, short paragraphs)
- [ ] Clear CTAs (calls-to-action)
- [ ] Internal links to related content
- [ ] External links to authoritative sources
### Metadata
- [ ] Unique title tag with primary keyword
- [ ] Compelling meta description
- [ ] Open Graph tags (og:title, og:description, og:image)
- [ ] Twitter Card metadata
- [ ] Canonical URL specified
- [ ] Language and locale tags
### Structured Data
- [ ] Schema.org markup (JSON-LD format)
- [ ] Breadcrumb schema
- [ ] Article/Product/Service schema as appropriate
- [ ] FAQ schema for Q&A sections
- [ ] Organization schema
- [ ] Review/Rating schema when applicable
### Performance
- [ ] Images optimized and lazy-loaded
- [ ] Critical CSS inlined
- [ ] JavaScript deferred/async
- [ ] Asset compression (gzip/brotli)
- [ ] CDN for static assets
- [ ] Browser caching configured
### Accessibility
- [ ] Semantic HTML elements
- [ ] ARIA labels where needed
- [ ] Keyboard navigation support
- [ ] Sufficient color contrast (4.5:1+)
- [ ] Focus indicators visible
- [ ] Screen reader tested
- [ ] Alt text for images
### Mobile Optimization
- [ ] Responsive design (viewport meta tag)
- [ ] Touch targets ≥44x44px
- [ ] No horizontal scrolling
- [ ] Readable font sizes (16px+)
- [ ] Fast mobile load time
- [ ] Mobile-friendly navigation
## Content Patterns
### Blog Post/Article
```markdown
# Primary Keyword: Clear, Compelling Headline
[Featured image with descriptive alt text]
**Introduction (150-200 words):**
- Answer the main question immediately
- Include primary keyword naturally
- Preview what reader will learn
## Main Section 1 (H2 with Secondary Keyword)
Content that addresses user intent...
### Subsection 1.1 (H3)
Detailed information...
## Main Section 2 (H2)
More valuable content...
## FAQ Section
### Question 1?
Direct answer here...
## Conclusion
- Summarize key takeaways
- Include clear CTA
- Suggest related content
```
### Product/Service Page
```markdown
# Product Name - Primary Benefit
**Above the fold:**
- Clear value proposition
- Hero image/video
- Primary CTA button
**Key Features** (with icons)
- Feature 1: Benefit
- Feature 2: Benefit
- Feature 3: Benefit
**Detailed Description**
Comprehensive, scannable content...
**Social Proof**
- Reviews and ratings
- Testimonials
- Trust badges
**FAQ**
Answer common objections...
**Final CTA**
Clear next step...
```
### Landing Page
```markdown
# Compelling Headline: Primary Benefit
**Hero Section:**
- Clear, benefit-focused headline
- Supporting subheadline
- Hero visual
- Primary CTA
**Problem/Solution:**
Address pain points...
**How It Works** (3-step process)
1. Step 1
2. Step 2
3. Step 3
**Social Proof:**
Testimonials, logos, stats...
**Features/Benefits:**
What makes this valuable...
**Final CTA:**
Remove friction, clear action...
```
## SEO/AEO Writing Tips
### Answer User Intent
- **Informational**: Provide comprehensive, accurate information
- **Navigational**: Clear navigation and internal linking
- **Transactional**: Remove friction, clear CTAs
- **Commercial**: Comparison, reviews, specifications
### Optimize for Featured Snippets
- **Paragraph**: 40-60 word direct answer
- **List**: Numbered/bulleted lists
- **Table**: Comparison or data tables
- **Video**: Timestamped, transcribed content
### E-E-A-T Signals
- Author bios with credentials
- Citations and sources
- Regular content updates
- Transparent about site/business
- Contact information visible
- Privacy policy and terms
### Keyword Strategy
- Primary keyword in: title, H1, first paragraph, URL
- Secondary keywords in: H2s, throughout content
- LSI (Latent Semantic Indexing) keywords naturally
- Long-tail keywords for specific queries
- Avoid keyword stuffing (keep natural)
## Common Pitfalls to Avoid
❌ **Don't:**
- Keyword stuff (sounds unnatural)
- Use thin, duplicate content
- Hide text or links
- Ignore mobile users
- Skip alt text
- Use generic meta descriptions
- Create orphan pages (no internal links)
- Ignore page speed
- Forget about accessibility
- Write for bots instead of humans
✅ **Do:**
- Write naturally for users first
- Create original, valuable content
- Use clear, semantic structure
- Prioritize mobile experience
- Describe images meaningfully
- Craft unique, compelling metadata
- Build logical internal linking
- Optimize performance continuously
- Design inclusively
- Think: "Would this help my users?"
## Tools for Validation
- **Performance**: Lighthouse, PageSpeed Insights, WebPageTest
- **SEO**: Google Search Console, Screaming Frog, Ahrefs
- **Accessibility**: WAVE, axe DevTools, Screen readers
- **Structured Data**: Google Rich Results Test, Schema Validator
- **Mobile**: Mobile-Friendly Test, BrowserStack
## When to Use This Skill
- Creating new pages or content
- Optimizing existing content for search
- Structuring information architecture
- Writing copy for landing pages
- Adding metadata and structured data
- Improving accessibility
- Optimizing performance
- Planning content strategy
- Conducting content audits

232
IMPLEMENTATION_STATUS.md Normal file
View File

@ -0,0 +1,232 @@
# Pottery Diary - Feature Enhancement Implementation Status
## ✅ COMPLETED - ALL PHASES (1-10)
### Phase 1: Database Schema ✅
- **Updated**: `src/lib/db/schema.ts`
- Schema version bumped to 8
- Added `weight_unit` column to `settings` table
- Created `user_lists` table for shopping/wish lists
- Created `forming_fields` table for production steps
- Added indexes for new tables
- **Updated**: `src/lib/db/index.ts`
- Added migration from v7 to v8
- Adds kiln fields to `firing_fields`
- Adds `glaze_layers` to `glazing_fields`
- Creates `user_lists` and `forming_fields` tables
### Phase 2: Type System ✅
- **Updated**: `src/types/index.ts`
- Added `WeightUnit`, `Weight` types
- Added `ProductionMethod` type
- Added `DimensionUnit`, `Dimensions` types
- Added `FormingFields` interface
- Added `KilnType`, `KilnPosition` types
- Enhanced `FiringFields` with kiln fields
- Added `GlazePosition`, `GlazeLayer` types
- Enhanced `GlazingFields` with multi-layer support (backwards compatible)
- Added `UserListType`, `UserListItem` types
- Enhanced `Settings` with `weightUnit`
- Updated `Step` union type to include forming
- Added `isForming()` type guard
### Phase 3: Utility Functions ✅
- **Created**: `src/lib/utils/weightConversions.ts`
- `convertWeight()` - Convert between weight units
- `formatWeight()` - Format weight for display
- **Created**: `src/lib/utils/dimensionUtils.ts`
- `formatDimensions()` - Format dimensions for display
- `convertDimensionUnit()` - Convert between inch/cm
- **Updated**: `src/lib/utils/index.ts`
- Exported new utility functions
### Phase 4: Repository Layer ✅
- **Created**: `src/lib/db/repositories/userListRepository.ts`
- `createListItem()` - Create shopping/wish list item
- `getListItems()` - Get all list items for user
- `updateListItem()` - Update list item
- `deleteListItem()` - Delete list item
- `toggleListItemCompleted()` - Toggle completion status
- **Updated**: `src/lib/db/repositories/settingsRepository.ts`
- Added `weightUnit` support in `getSettings()`
- Added `weightUnit` support in `updateSettings()`
- Default weight unit is 'lb'
- **Updated**: `src/lib/db/repositories/stepRepository.ts`
- Added `FormingFields` to `CreateStepParams`
- Updated `createStep()` to handle forming fields
- Updated `createStep()` with enhanced firing fields (kiln type, position, schedule)
- Updated `createStep()` with glaze_layers support
- Updated `getStep()` to fetch forming fields
- Updated `getStep()` with enhanced firing fields
- Updated `getStep()` with glaze_layers (backwards compatible migration)
- Updated `updateStep()` to handle all new field types
- **Updated**: `src/lib/db/repositories/index.ts`
- Exported `userListRepository`
### Phase 5: UI Components ✅
- **Created**: `src/components/Picker.tsx`
- **Created**: `src/components/WeightInput.tsx`
- **Created**: `src/components/ButtonGrid.tsx`
- **Created**: `src/components/DimensionsInput.tsx`
- **Created**: `src/components/GlazeLayerCard.tsx`
- **Updated**: `src/components/index.ts` to export all new components ✅
### Phase 6: StepEditorScreen Enhancements ✅
**File**: `src/screens/StepEditorScreen.tsx`
**Completed Changes**:
1. ✅ Added new state variables for all new fields
2. ✅ Load user preferences (weight unit, dimension unit)
3. ✅ Updated `loadStep()` to handle forming fields and all new fields
4. ✅ Updated `handleSave()` to save all new field types
5. ✅ Added render functions:
- `renderFormingFields()` - Clay body, weight, method, dimensions
- Updated `renderFiringFields()` - Add kiln type, position, schedule
- Updated `renderGlazingFields()` - Multi-layer with custom glaze support
6. ✅ Updated notes field to be larger (8 lines, 5000 char limit with counter)
7. ✅ Added sticky save button at bottom
8. ✅ Updated auto-save dependencies to include all new fields
### Phase 7: GlazePicker Integration ✅
**File**: `src/screens/GlazePickerScreen.tsx`
**Completed Changes**:
1. ✅ Added `mode` parameter support ('add_layer' vs 'replace')
2. ✅ Updated `handleSave()` to create `GlazeLayer` objects when mode is 'add_layer'
3. ✅ Navigate back with `newGlazeLayers` param
### Phase 8: Navigation Types ✅
**File**: `src/navigation/types.ts`
**Completed Changes**:
1. ✅ Added `newGlazeLayers?: GlazeLayer[]` to StepEditor params
2. ✅ Added `mode?: 'add_layer' | 'replace'` to GlazePicker params
3. ✅ Added `UserList: { type: UserListType }` to RootStackParamList
### Phase 9: User Lists Feature ✅
**Files**:
- ✅ Created `src/screens/UserListScreen.tsx` - Full CRUD for shopping/wish lists
- ✅ Updated `src/screens/index.ts` - Export UserListScreen
- ✅ Updated `src/navigation/index.tsx` - Add UserList route
### Phase 10: Settings Screen Enhancements ✅
**File**: `src/screens/SettingsScreen.tsx`
**Completed Changes**:
1. ✅ Added weight unit toggle (same pattern as temp unit)
2. ✅ Added "My Lists" section with buttons for Shopping List and Wish List
3. ✅ Updated `handleExportData()` to include user lists
---
## 📋 Implementation Summary
### All Features Completed
- ✅ Database can store all new fields
- ✅ Types are defined for all features
- ✅ Repositories can create/read/update all new data
- ✅ Weight and dimension conversion utilities ready
- ✅ All UI components created (Picker, WeightInput, ButtonGrid, DimensionsInput, GlazeLayerCard)
- ✅ StepEditorScreen fully updated with all new fields
- ✅ GlazePicker updated for multi-layer support
- ✅ UserListScreen created with full CRUD
- ✅ SettingsScreen updated with weight unit toggle and list navigation
- ✅ Navigation types updated for all new features
---
## 🔧 Technical Notes
### Backwards Compatibility
The implementation includes lazy migration for old glazing data:
- Old format: `{ glazeIds: [...], application: '...', coats: 2 }`
- New format: `{ glazeLayers: [{glazeId, application, position, coats, notes}] }`
- On read, old data is automatically converted to new format
### Database Migration
Migration v7→v8 uses `ALTER TABLE` to add columns, preserving existing data.
New tables are created from scratch.
### Type Safety
All new types are discriminated unions with type guards, maintaining the existing pattern.
---
## 📝 Files Modified/Created Summary
### Modified Files (13)
1. `src/lib/db/schema.ts`
2. `src/lib/db/index.ts`
3. `src/types/index.ts`
4. `src/lib/utils/index.ts`
5. `src/lib/db/repositories/index.ts`
6. `src/lib/db/repositories/settingsRepository.ts`
7. `src/lib/db/repositories/stepRepository.ts`
### Created Files (6)
8. `src/lib/utils/weightConversions.ts`
9. `src/lib/utils/dimensionUtils.ts`
10. `src/lib/db/repositories/userListRepository.ts`
11. `src/components/Picker.tsx`
12. `src/components/WeightInput.tsx`
13. `src/components/ButtonGrid.tsx`
### Files Pending Creation/Modification
- `src/components/DimensionsInput.tsx` (create)
- `src/components/GlazeLayerCard.tsx` (create)
- `src/components/index.ts` (update exports)
- `src/screens/StepEditorScreen.tsx` (major update)
- `src/screens/GlazePickerScreen.tsx` (minor update)
- `src/screens/UserListScreen.tsx` (create)
- `src/screens/SettingsScreen.tsx` (update)
- `src/screens/index.ts` (update exports)
- `src/navigation/types.ts` (update)
- `src/navigation/index.tsx` (add route)
---
## ✨ Features Ready to Use
All features are now fully implemented and ready to use:
- ✅ Settings with weight_unit field and unit toggles
- ✅ Firing steps with kiln type, position, and schedule
- ✅ Glazing steps with multi-layer support (custom + catalog glazes)
- ✅ Forming steps with clay body, weight, production method, and dimensions
- ✅ Shopping and wish lists with full CRUD
- ✅ Weight and dimension conversions
- ✅ Enhanced notes field (8 lines, 5000 char limit)
- ✅ Data export includes all new fields
---
## 🎯 Next Steps - Testing & Deployment
All implementation is complete! Recommended testing:
1. **Test Database Migration**
- Run the app to trigger v7→v8 migration
- Verify all existing data is preserved
- Confirm new tables are created
2. **Test New Features**
- Create a forming step with all fields
- Create a firing step with kiln details
- Create a glazing step with multiple layers
- Test shopping and wish lists
- Test weight unit toggle in Settings
- Test data export
3. **End-to-End Testing**
- Create a complete project with all step types
- Verify auto-save works
- Test backwards compatibility
- Verify navigation flows
Refer to the detailed plan at:
`C:\Users\a931627\.claude\plans\refactored-wibbling-tide.md`

BIN
assets/images/home_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -0,0 +1,14 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- House outline -->
<path d="M24 8L10 20V38C10 38.5304 10.2107 39.0391 10.5858 39.4142C10.9609 39.7893 11.4696 40 12 40H20V30H28V40H36C36.5304 40 37.0391 39.7893 37.4142 39.4142C37.7893 39.0391 38 38.5304 38 38V20L24 8Z"
stroke="#D6733F"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
<!-- Small pottery vase inside -->
<path d="M24 16C24 16 22 17 22 19V22C22 23 23 24 24 24C25 24 26 23 26 22V19C26 17 24 16 24 16Z"
fill="#D6733F"
opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
assets/images/tips_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

6
fehl.md Normal file
View File

@ -0,0 +1,6 @@
Im step notes feld doppelt so groß
farbpalette
fotos?
unter kategorie bei Forming/drying
glazing application methode nach oben, dann number of coats, glaze

14
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.4.9",
"@react-navigation/native": "^7.1.18",
"@react-navigation/native-stack": "^7.3.28",
@ -2856,6 +2857,19 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz",
"integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==",
"license": "MIT",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.4",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.4.tgz",

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.4.9",
"@react-navigation/native": "^7.1.18",
"@react-navigation/native-stack": "^7.3.28",

73
src/components/Badge.tsx Normal file
View File

@ -0,0 +1,73 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
export interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md';
style?: ViewStyle;
}
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
style,
}) => {
return (
<View style={[styles.base, styles[variant], styles[`size_${size}`], style]}>
<Text style={[styles.text, styles[`text_${size}`]]}>
{children}
</Text>
</View>
);
};
const styles = StyleSheet.create({
base: {
borderRadius: borderRadius.full,
borderWidth: 2,
alignSelf: 'flex-start',
},
default: {
backgroundColor: colors.primaryLight,
borderColor: colors.primary,
},
success: {
backgroundColor: colors.successLight,
borderColor: colors.success,
},
warning: {
backgroundColor: colors.primaryLight,
borderColor: colors.warning,
},
error: {
backgroundColor: '#FEE2E2',
borderColor: colors.error,
},
info: {
backgroundColor: colors.primaryLight,
borderColor: colors.info,
},
size_sm: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
size_md: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
text: {
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
text_sm: {
fontSize: typography.fontSize.xs,
},
text_md: {
fontSize: typography.fontSize.xs,
},
});

View File

@ -124,7 +124,7 @@ const styles = StyleSheet.create({
textTransform: 'uppercase', // Retro all-caps buttons
},
text_primary: {
color: colors.background,
color: colors.buttonText,
},
text_secondary: {
color: colors.text,

View File

@ -0,0 +1,44 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Button } from './Button';
import { spacing } from '../lib/theme';
interface ButtonGridProps {
options: { value: string; label: string }[];
selectedValue: string;
onSelect: (value: string) => void;
columns?: number;
}
export const ButtonGrid: React.FC<ButtonGridProps> = ({
options,
selectedValue,
onSelect,
columns = 2,
}) => {
return (
<View style={styles.container}>
{options.map(option => (
<View key={option.value} style={[styles.buttonWrapper, { width: `${100 / columns}%` }]}>
<Button
title={option.label}
onPress={() => onSelect(option.value)}
variant={selectedValue === option.value ? 'primary' : 'outline'}
size="sm"
/>
</View>
))}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2,
},
buttonWrapper: {
padding: spacing.xs / 2,
},
});

View File

@ -1,6 +1,6 @@
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import { colors, spacing, borderRadius, shadows } from '../lib/theme';
import { View, Text, StyleSheet, StyleProp, ViewStyle, TextStyle } from 'react-native';
import { colors, spacing, borderRadius, shadows, typography } from '../lib/theme';
interface CardProps {
children: React.ReactNode;
@ -8,7 +8,33 @@ interface CardProps {
elevated?: boolean;
}
export const Card: React.FC<CardProps> = ({ children, style, elevated = true }) => {
interface CardHeaderProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
interface CardTitleProps {
children: React.ReactNode;
style?: StyleProp<TextStyle>;
}
interface CardContentProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
interface CardFooterProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
// Main Card Component with Compound Components (shadcn/ui style)
export const Card: React.FC<CardProps> & {
Header: React.FC<CardHeaderProps>;
Title: React.FC<CardTitleProps>;
Content: React.FC<CardContentProps>;
Footer: React.FC<CardFooterProps>;
} = ({ children, style, elevated = true }) => {
return (
<View style={[styles.card, elevated && shadows.md, style]}>
{children}
@ -16,12 +42,58 @@ export const Card: React.FC<CardProps> = ({ children, style, elevated = true })
);
};
// Card.Header
Card.Header = ({ children, style }) => (
<View style={[styles.header, style]}>
{children}
</View>
);
// Card.Title
Card.Title = ({ children, style }) => (
<Text style={[styles.title, style]}>
{children}
</Text>
);
// Card.Content
Card.Content = ({ children, style }) => (
<View style={[styles.content, style]}>
{children}
</View>
);
// Card.Footer
Card.Footer = ({ children, style }) => (
<View style={[styles.footer, 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
padding: spacing.md,
borderWidth: 2,
borderColor: colors.border,
},
header: {
marginBottom: spacing.sm,
},
title: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
letterSpacing: 0.3,
},
content: {
gap: spacing.sm,
},
footer: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
});

View File

@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { Button } from './Button';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { Dimensions, DimensionUnit } from '../types';
interface DimensionsInputProps {
label?: string;
value: Dimensions | undefined;
onChange: (dims: Dimensions | undefined) => void;
userPreferredUnit: DimensionUnit;
}
export const DimensionsInput: React.FC<DimensionsInputProps> = ({
label,
value,
onChange,
userPreferredUnit,
}) => {
const [height, setHeight] = useState('');
const [width, setWidth] = useState('');
const [diameter, setDiameter] = useState('');
const [notes, setNotes] = useState('');
const [unit, setUnit] = useState<DimensionUnit>(userPreferredUnit);
useEffect(() => {
if (value) {
setHeight(value.height?.toString() || '');
setWidth(value.width?.toString() || '');
setDiameter(value.diameter?.toString() || '');
setNotes(value.notes || '');
setUnit(value.unit);
}
}, [value]);
const updateDimensions = (updates: Partial<Dimensions>) => {
const newHeight = updates.height !== undefined ? updates.height : parseFloat(height);
const newWidth = updates.width !== undefined ? updates.width : parseFloat(width);
const newDiameter = updates.diameter !== undefined ? updates.diameter : parseFloat(diameter);
const newNotes = updates.notes !== undefined ? updates.notes : notes;
const newUnit = updates.unit !== undefined ? updates.unit : unit;
const hasAnyValue = !isNaN(newHeight) || !isNaN(newWidth) || !isNaN(newDiameter);
if (!hasAnyValue) {
onChange(undefined);
return;
}
onChange({
height: !isNaN(newHeight) ? newHeight : undefined,
width: !isNaN(newWidth) ? newWidth : undefined,
diameter: !isNaN(newDiameter) ? newDiameter : undefined,
unit: newUnit,
notes: newNotes || undefined,
});
};
const handleHeightChange = (text: string) => {
setHeight(text);
updateDimensions({ height: parseFloat(text) || undefined });
};
const handleWidthChange = (text: string) => {
setWidth(text);
updateDimensions({ width: parseFloat(text) || undefined });
};
const handleDiameterChange = (text: string) => {
setDiameter(text);
updateDimensions({ diameter: parseFloat(text) || undefined });
};
const handleNotesChange = (text: string) => {
setNotes(text);
updateDimensions({ notes: text || undefined });
};
const handleUnitChange = (newUnit: DimensionUnit) => {
setUnit(newUnit);
updateDimensions({ unit: newUnit });
};
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={styles.unitToggle}>
<Button
title="in"
onPress={() => handleUnitChange('in')}
variant={unit === 'in' ? 'primary' : 'outline'}
size="sm"
/>
<Button
title="cm"
onPress={() => handleUnitChange('cm')}
variant={unit === 'cm' ? 'primary' : 'outline'}
size="sm"
/>
</View>
<View style={styles.row}>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Height</Text>
<TextInput
style={styles.input}
value={height}
onChangeText={handleHeightChange}
placeholder="0.0"
keyboardType="decimal-pad"
/>
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Width</Text>
<TextInput
style={styles.input}
value={width}
onChangeText={handleWidthChange}
placeholder="0.0"
keyboardType="decimal-pad"
/>
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Diameter</Text>
<TextInput
style={styles.input}
value={diameter}
onChangeText={handleDiameterChange}
placeholder="0.0"
keyboardType="decimal-pad"
/>
</View>
</View>
<TextInput
style={styles.notesInput}
value={notes}
onChangeText={handleNotesChange}
placeholder="Dimension notes (optional)"
multiline
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text,
marginBottom: spacing.xs,
},
unitToggle: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm,
},
row: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.sm,
},
field: {
flex: 1,
},
fieldLabel: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
input: {
height: 40,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.sm,
backgroundColor: colors.background,
fontSize: typography.fontSize.md,
color: colors.text,
},
notesInput: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.sm,
backgroundColor: colors.background,
fontSize: typography.fontSize.sm,
color: colors.text,
minHeight: 60,
},
});

View File

@ -0,0 +1,65 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Button } from './Button';
import { colors, spacing, typography } from '../lib/theme';
export interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon = '📭',
title,
description,
actionLabel,
onAction,
}) => {
return (
<View style={styles.container}>
<Text style={styles.icon}>{icon}</Text>
<Text style={styles.title}>{title}</Text>
{description && <Text style={styles.description}>{description}</Text>}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
size="md"
style={styles.button}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
icon: {
fontSize: 64,
marginBottom: spacing.md,
},
title: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.semiBold,
color: colors.text,
marginBottom: spacing.sm,
textAlign: 'center',
},
description: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.lg,
textAlign: 'center',
},
button: {
marginTop: spacing.md,
},
});

View File

@ -0,0 +1,187 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { Card } from './Card';
import { Button } from './Button';
import { Picker } from './Picker';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { GlazeLayer, ApplicationMethod, GlazePosition } from '../types';
import { getGlaze } from '../lib/db/repositories';
interface GlazeLayerCardProps {
layer: GlazeLayer;
layerNumber: number;
onUpdate: (layer: GlazeLayer) => void;
onRemove: () => void;
}
export const GlazeLayerCard: React.FC<GlazeLayerCardProps> = ({
layer,
layerNumber,
onUpdate,
onRemove,
}) => {
const [glazeName, setGlazeName] = useState<string>(
layer.customGlazeName || layer.glazeId || 'Unknown Glaze'
);
// Load glaze name if we have a glazeId
useEffect(() => {
if (layer.glazeId && !layer.customGlazeName) {
loadGlazeName();
}
}, [layer.glazeId]);
const loadGlazeName = async () => {
if (!layer.glazeId) return;
try {
const glaze = await getGlaze(layer.glazeId);
if (glaze) {
setGlazeName(`${glaze.brand} ${glaze.name}`);
}
} catch (error) {
console.error('Failed to load glaze:', error);
setGlazeName(layer.glazeId); // Fallback to ID
}
};
const handleApplicationChange = (value: string) => {
onUpdate({ ...layer, application: value as ApplicationMethod });
};
const handlePositionChange = (value: string) => {
onUpdate({ ...layer, position: value as GlazePosition });
};
const handleCoatsChange = (text: string) => {
const coats = parseInt(text);
onUpdate({ ...layer, coats: isNaN(coats) ? undefined : coats });
};
const handleNotesChange = (text: string) => {
onUpdate({ ...layer, notes: text || undefined });
};
return (
<Card style={styles.container}>
<View style={styles.header}>
<Text style={styles.layerNumber}>Layer {layerNumber}</Text>
<Button
title="Remove"
onPress={onRemove}
variant="outline"
size="sm"
/>
</View>
<Text style={styles.glazeName}>{glazeName}</Text>
<Picker
label="Application Method"
value={layer.application}
options={[
{ value: 'brush', label: 'Brushing' },
{ value: 'dip', label: 'Dipping' },
{ value: 'pour', label: 'Pouring' },
{ value: 'spray', label: 'Spraying' },
{ value: 'other', label: 'Other' },
]}
onValueChange={handleApplicationChange}
/>
<Picker
label="Position on Piece"
value={layer.position}
options={[
{ value: 'entire', label: 'Entire Piece' },
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'inside', label: 'Inside' },
{ value: 'outside', label: 'Outside' },
{ value: 'other', label: 'Other' },
]}
onValueChange={handlePositionChange}
/>
<View style={styles.coatsContainer}>
<Text style={styles.label}>Coats</Text>
<TextInput
style={styles.coatsInput}
value={layer.coats?.toString() || ''}
onChangeText={handleCoatsChange}
placeholder="1"
keyboardType="number-pad"
/>
</View>
<View style={styles.notesContainer}>
<Text style={styles.label}>Layer Notes</Text>
<TextInput
style={styles.notesInput}
value={layer.notes || ''}
onChangeText={handleNotesChange}
placeholder="Notes for this layer"
multiline
numberOfLines={2}
/>
</View>
</Card>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
padding: spacing.md,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
layerNumber: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semiBold,
color: colors.primary,
},
glazeName: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.medium,
color: colors.text,
marginBottom: spacing.md,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text,
marginBottom: spacing.xs,
},
coatsContainer: {
marginBottom: spacing.md,
},
coatsInput: {
height: 48,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background,
fontSize: typography.fontSize.md,
color: colors.text,
},
notesContainer: {
marginBottom: 0,
},
notesInput: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.sm,
backgroundColor: colors.background,
fontSize: typography.fontSize.sm,
color: colors.text,
minHeight: 60,
},
});

78
src/components/Picker.tsx Normal file
View File

@ -0,0 +1,78 @@
import React from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { Picker as RNPicker } from '@react-native-picker/picker';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
interface PickerProps {
label?: string;
value: string;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
placeholder?: string;
}
export const Picker: React.FC<PickerProps> = ({
label,
value,
options,
onValueChange,
placeholder,
}) => {
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={styles.pickerContainer}>
<RNPicker
selectedValue={value}
onValueChange={onValueChange}
style={styles.picker}
dropdownIconColor={colors.text}
enabled={true}
>
{placeholder && (
<RNPicker.Item
label={placeholder}
value=""
color={colors.text}
/>
)}
{options.map(option => (
<RNPicker.Item
key={option.value}
label={option.label}
value={option.value}
color={colors.text}
/>
))}
</RNPicker>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text,
marginBottom: spacing.xs,
},
pickerContainer: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
backgroundColor: colors.background,
overflow: 'hidden',
minHeight: 30,
maxHeight: 125,
justifyContent: 'flex-start',
},
picker: {
width: '100%',
color: colors.text,
marginTop: -70,
},
});

View File

@ -1,5 +1,6 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { colors } from '../lib/theme';
interface PotteryIconProps {
color: string;
@ -106,24 +107,24 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
borderWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
vaseNeck: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
vaseBody: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
borderRadius: 8,
},
vaseBase: {
borderBottomLeftRadius: 5,
borderBottomRightRadius: 5,
borderWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
shine: {
position: 'absolute',
@ -138,20 +139,20 @@ const styles = StyleSheet.create({
borderTopWidth: 2,
borderLeftWidth: 2,
borderRightWidth: 2,
borderColor: '#8B7355',
borderColor: colors.border,
marginTop: -1, // Overlap to connect
},
bowlLayer: {
borderLeftWidth: 2,
borderRightWidth: 2,
borderColor: '#8B7355',
borderColor: colors.border,
marginTop: -1, // Overlap to connect smoothly
},
bowlBase: {
borderBottomWidth: 2,
borderLeftWidth: 2,
borderRightWidth: 2,
borderColor: '#8B7355',
borderColor: colors.border,
marginTop: -1, // Overlap to connect
},
@ -162,12 +163,12 @@ const styles = StyleSheet.create({
borderTopWidth: 2,
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
mugBody: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
mugBase: {
borderBottomLeftRadius: 5,
@ -175,7 +176,7 @@ const styles = StyleSheet.create({
borderBottomWidth: 2,
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: '#8B7355',
borderColor: colors.border,
},
handle: {
width: 15,
@ -190,6 +191,6 @@ const styles = StyleSheet.create({
circle: {
borderRadius: 100,
borderWidth: 2,
borderColor: '#8B7355',
borderColor: colors.border,
},
});

View File

@ -0,0 +1,160 @@
import React from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native';
import { Card } from './Card';
import { Badge } from './Badge';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { formatDate } from '../lib/utils/datetime';
import { Project } from '../types';
export interface ProjectCardProps {
project: Project;
onPress: (projectId: string) => void;
variant?: 'grid' | 'list';
showStepCount?: boolean;
stepCount?: number;
}
export const ProjectCard: React.FC<ProjectCardProps> = ({
project,
onPress,
variant = 'grid',
showStepCount = false,
stepCount = 0,
}) => {
const statusVariant = project.status === 'done' ? 'success' : 'info';
const statusText = project.status === 'in_progress' ? 'In Progress' : 'Done';
return (
<TouchableOpacity
onPress={() => onPress(project.id)}
accessibilityRole="button"
accessibilityLabel={`Open project ${project.title}`}
style={variant === 'grid' ? styles.gridCard : styles.listCard}
>
<Card>
{/* Cover Image */}
{project.coverImageUri ? (
<View style={styles.imageContainer}>
<Image
source={{ uri: project.coverImageUri }}
style={styles.coverImage}
accessibilityLabel={`Cover image for ${project.title}`}
/>
{showStepCount && stepCount > 0 && (
<View style={styles.stepBadge}>
<Text style={styles.stepBadgeText}>{stepCount} steps</Text>
</View>
)}
</View>
) : (
<View style={[styles.coverImage, styles.placeholderImage]}>
<Text style={styles.placeholderIcon}>🏺</Text>
</View>
)}
{/* Card Content */}
<Card.Content>
<Card.Title>{project.title}</Card.Title>
<View style={styles.meta}>
<Badge variant={statusVariant} size="sm">
{statusText}
</Badge>
</View>
{/* Tags */}
{project.tags.length > 0 && (
<View style={styles.tags}>
{project.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="default" size="sm">
{tag}
</Badge>
))}
{project.tags.length > 3 && (
<Text style={styles.moreTagsText}>
+{project.tags.length - 3}
</Text>
)}
</View>
)}
</Card.Content>
{/* Card Footer */}
<Card.Footer>
<Text style={styles.date}>
{formatDate(project.updatedAt)}
</Text>
</Card.Footer>
</Card>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
gridCard: {
flex: 1,
marginBottom: spacing.md,
},
listCard: {
marginBottom: spacing.md,
},
imageContainer: {
position: 'relative',
marginBottom: spacing.md,
},
coverImage: {
width: '100%',
height: 150,
borderRadius: borderRadius.md,
backgroundColor: colors.backgroundSecondary,
borderWidth: 2,
borderColor: colors.border,
},
placeholderImage: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.borderLight,
},
placeholderIcon: {
fontSize: 48,
opacity: 0.5,
},
stepBadge: {
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,
},
stepBadgeText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.background,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
meta: {
flexDirection: 'row',
alignItems: 'center',
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginTop: spacing.xs,
},
moreTagsText: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
alignSelf: 'center',
},
date: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
fontWeight: typography.fontWeight.medium,
},
});

View File

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { Button } from './Button';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { Weight, WeightUnit } from '../types';
import { convertWeight } from '../lib/utils/weightConversions';
interface WeightInputProps {
label?: string;
value: Weight | undefined;
onChange: (weight: Weight | undefined) => void;
userPreferredUnit: WeightUnit;
}
export const WeightInput: React.FC<WeightInputProps> = ({
label,
value,
onChange,
userPreferredUnit,
}) => {
const [inputValue, setInputValue] = useState('');
const [currentUnit, setCurrentUnit] = useState<WeightUnit>(userPreferredUnit);
useEffect(() => {
if (value) {
setInputValue(value.value.toString());
setCurrentUnit(value.unit);
} else {
setInputValue('');
setCurrentUnit(userPreferredUnit);
}
}, [value]);
const handleValueChange = (text: string) => {
setInputValue(text);
const numValue = parseFloat(text);
if (!isNaN(numValue) && numValue > 0) {
onChange({ value: numValue, unit: currentUnit });
} else if (text === '') {
onChange(undefined);
}
};
const handleUnitChange = (newUnit: WeightUnit) => {
if (value) {
const converted = convertWeight(value, newUnit);
setInputValue(converted.value.toString());
setCurrentUnit(newUnit);
onChange(converted);
} else {
setCurrentUnit(newUnit);
}
};
const unitOptions = userPreferredUnit === 'lb' || userPreferredUnit === 'oz'
? ['lb', 'oz'] as WeightUnit[]
: ['kg', 'g'] as WeightUnit[];
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={styles.row}>
<TextInput
style={styles.input}
value={inputValue}
onChangeText={handleValueChange}
placeholder="0.0"
keyboardType="decimal-pad"
/>
<View style={styles.unitButtons}>
{unitOptions.map(unit => (
<Button
key={unit}
title={unit}
onPress={() => handleUnitChange(unit)}
variant={currentUnit === unit ? 'primary' : 'outline'}
size="sm"
style={styles.unitButton}
/>
))}
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text,
marginBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
input: {
flex: 1,
height: 48,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background,
fontSize: typography.fontSize.md,
color: colors.text,
},
unitButtons: {
flexDirection: 'row',
gap: spacing.xs,
},
unitButton: {
minWidth: 50,
},
});

View File

@ -2,3 +2,11 @@ export * from './Button';
export * from './Card';
export * from './Input';
export * from './PotteryIcon';
export * from './Picker';
export * from './WeightInput';
export * from './DimensionsInput';
export * from './GlazeLayerCard';
export * from './ButtonGrid';
export * from './Badge';
export * from './ProjectCard';
export * from './EmptyState';

View File

@ -154,6 +154,63 @@ async function migrateDatabase(
console.log('Migration to version 7 complete - multi-user support added');
console.log('Note: All existing data was cleared. Glazes will be re-seeded.');
}
// Migration from v7 to v8: Add enhanced features
if (fromVersion < 8) {
console.log('Migrating to version 8: Adding enhanced pottery workflow features');
// Add weight_unit to settings
await database.execAsync(`
ALTER TABLE settings ADD COLUMN weight_unit TEXT NOT NULL DEFAULT 'lb' CHECK(weight_unit IN ('lb', 'oz', 'kg', 'g'));
`);
// Add kiln fields to firing_fields
await database.execAsync(`
ALTER TABLE firing_fields ADD COLUMN kiln_type TEXT CHECK(kiln_type IN ('electric', 'gas', 'raku', 'other'));
ALTER TABLE firing_fields ADD COLUMN kiln_custom_name TEXT;
ALTER TABLE firing_fields ADD COLUMN kiln_position TEXT CHECK(kiln_position IN ('top', 'middle', 'bottom', 'other'));
ALTER TABLE firing_fields ADD COLUMN kiln_position_notes TEXT;
ALTER TABLE firing_fields ADD COLUMN firing_schedule TEXT;
`);
// Add glaze_layers to glazing_fields for multi-layer support
await database.execAsync(`
ALTER TABLE glazing_fields ADD COLUMN glaze_layers TEXT;
`);
// Create user_lists table
await database.execAsync(`
CREATE TABLE IF NOT EXISTS user_lists (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('shopping', 'wish')),
item TEXT NOT NULL,
notes TEXT,
is_completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES settings(user_id)
);
CREATE INDEX IF NOT EXISTS idx_user_lists_user_id ON user_lists(user_id);
CREATE INDEX IF NOT EXISTS idx_user_lists_type ON user_lists(type);
`);
// Create forming_fields table
await database.execAsync(`
CREATE TABLE IF NOT EXISTS forming_fields (
step_id TEXT PRIMARY KEY NOT NULL,
clay_body TEXT,
clay_weight_value REAL,
clay_weight_unit TEXT CHECK(clay_weight_unit IN ('lb', 'oz', 'kg', 'g')),
production_method TEXT CHECK(production_method IN ('wheel', 'handbuilding', 'casting', 'other')),
dimensions TEXT,
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
`);
await database.execAsync(`PRAGMA user_version = 8;`);
console.log('Migration to version 8 complete - Enhanced pottery workflow features added');
}
}
/**

View File

@ -3,3 +3,4 @@ export * from './stepRepository';
export * from './glazeRepository';
export * from './settingsRepository';
export * from './newsRepository';
export * from './userListRepository';

View File

@ -13,13 +13,14 @@ export async function getSettings(userId: string): Promise<Settings> {
const defaults: Settings = {
unitSystem: 'imperial',
tempUnit: 'F',
weightUnit: 'lb',
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]
`INSERT INTO settings (user_id, unit_system, temp_unit, weight_unit, analytics_opt_in)
VALUES (?, ?, ?, ?, ?)`,
[userId, defaults.unitSystem, defaults.tempUnit, defaults.weightUnit, defaults.analyticsOptIn ? 1 : 0]
);
return defaults;
@ -28,6 +29,7 @@ export async function getSettings(userId: string): Promise<Settings> {
return {
unitSystem: row.unit_system,
tempUnit: row.temp_unit,
weightUnit: row.weight_unit || 'lb',
analyticsOptIn: row.analytics_opt_in === 1,
};
}
@ -39,11 +41,12 @@ export async function updateSettings(userId: string, updates: Partial<Settings>)
await db.runAsync(
`UPDATE settings
SET unit_system = ?, temp_unit = ?, analytics_opt_in = ?
SET unit_system = ?, temp_unit = ?, weight_unit = ?, analytics_opt_in = ?
WHERE user_id = ?`,
[
updated.unitSystem,
updated.tempUnit,
updated.weightUnit,
updated.analyticsOptIn ? 1 : 0,
userId,
]

View File

@ -1,5 +1,5 @@
import { getDatabase } from '../index';
import { Step, StepType, FiringFields, GlazingFields } from '../../../types';
import { Step, StepType, FiringFields, GlazingFields, FormingFields } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
@ -10,6 +10,7 @@ interface CreateStepParams {
photoUris?: string[];
firing?: FiringFields;
glazing?: GlazingFields;
forming?: FormingFields;
}
export async function createStep(params: CreateStepParams): Promise<Step> {
@ -33,11 +34,24 @@ export async function createStep(params: CreateStepParams): Promise<Step> {
);
// Insert type-specific fields
if (params.type === 'bisque_firing' || params.type === 'glaze_firing') {
if (params.type === 'forming' && params.forming) {
await db.runAsync(
`INSERT INTO forming_fields (step_id, clay_body, clay_weight_value, clay_weight_unit, production_method, dimensions)
VALUES (?, ?, ?, ?, ?, ?)`,
[
stepId,
params.forming.clayBody || null,
params.forming.clayWeight?.value || null,
params.forming.clayWeight?.unit || null,
params.forming.productionMethod || null,
params.forming.dimensions ? JSON.stringify(params.forming.dimensions) : null,
]
);
} else 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 (?, ?, ?, ?, ?, ?)`,
`INSERT INTO firing_fields (step_id, cone, temperature_value, temperature_unit, duration_minutes, kiln_notes, kiln_type, kiln_custom_name, kiln_position, kiln_position_notes, firing_schedule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
stepId,
params.firing.cone || null,
@ -45,20 +59,26 @@ export async function createStep(params: CreateStepParams): Promise<Step> {
params.firing.temperature?.unit || null,
params.firing.durationMinutes || null,
params.firing.kilnNotes || null,
params.firing.kilnType || null,
params.firing.kilnCustomName || null,
params.firing.kilnPosition || null,
params.firing.kilnPositionNotes || null,
params.firing.firingSchedule || 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 (?, ?, ?, ?, ?)`,
`INSERT INTO glazing_fields (step_id, glaze_ids, coats, application, mix_notes, glaze_layers)
VALUES (?, ?, ?, ?, ?, ?)`,
[
stepId,
JSON.stringify(params.glazing.glazeIds),
JSON.stringify(params.glazing.glazeIds || []),
params.glazing.coats || null,
params.glazing.application || null,
params.glazing.mixNotes || null,
JSON.stringify(params.glazing.glazeLayers),
]
);
}
@ -96,7 +116,23 @@ export async function getStep(id: string): Promise<Step | null> {
};
// Fetch type-specific fields
if (stepRow.type === 'bisque_firing' || stepRow.type === 'glaze_firing') {
if (stepRow.type === 'forming') {
const formingRow = await db.getFirstAsync<any>(
'SELECT * FROM forming_fields WHERE step_id = ?',
[id]
);
const forming: FormingFields = {
clayBody: formingRow?.clay_body || undefined,
clayWeight: formingRow?.clay_weight_value
? { value: formingRow.clay_weight_value, unit: formingRow.clay_weight_unit }
: undefined,
productionMethod: formingRow?.production_method || undefined,
dimensions: formingRow?.dimensions ? JSON.parse(formingRow.dimensions) : undefined,
};
return { ...baseStep, type: 'forming', forming } as Step;
} else if (stepRow.type === 'bisque_firing' || stepRow.type === 'glaze_firing') {
const firingRow = await db.getFirstAsync<any>(
'SELECT * FROM firing_fields WHERE step_id = ?',
[id]
@ -109,6 +145,11 @@ export async function getStep(id: string): Promise<Step | null> {
: undefined,
durationMinutes: firingRow?.duration_minutes || undefined,
kilnNotes: firingRow?.kiln_notes || undefined,
kilnType: firingRow?.kiln_type || undefined,
kilnCustomName: firingRow?.kiln_custom_name || undefined,
kilnPosition: firingRow?.kiln_position || undefined,
kilnPositionNotes: firingRow?.kiln_position_notes || undefined,
firingSchedule: firingRow?.firing_schedule || undefined,
};
return { ...baseStep, type: stepRow.type, firing } as Step;
@ -118,8 +159,26 @@ export async function getStep(id: string): Promise<Step | null> {
[id]
);
let glazeLayers = [];
if (glazingRow?.glaze_layers) {
// New format
glazeLayers = JSON.parse(glazingRow.glaze_layers);
} else if (glazingRow?.glaze_ids) {
// OLD FORMAT - Migrate on read
const oldGlazeIds = JSON.parse(glazingRow.glaze_ids);
glazeLayers = oldGlazeIds.map((glazeId: string) => ({
glazeId,
application: glazingRow.application || 'brush',
position: 'entire',
coats: glazingRow.coats || 2,
notes: '',
}));
}
const glazing: GlazingFields = {
glazeIds: glazingRow ? JSON.parse(glazingRow.glaze_ids) : [],
glazeIds: glazingRow ? JSON.parse(glazingRow.glaze_ids || '[]') : [],
glazeLayers,
coats: glazingRow?.coats || undefined,
application: glazingRow?.application || undefined,
mixNotes: glazingRow?.mix_notes || undefined,
@ -170,14 +229,31 @@ export async function updateStep(id: string, updates: Partial<CreateStepParams>)
}
// Update type-specific fields
if (step.type === 'bisque_firing' || step.type === 'glaze_firing') {
if (step.type === 'forming' && updates.forming) {
const existingForming = 'forming' in step ? step.forming : {};
const mergedForming = { ...existingForming, ...updates.forming };
await db.runAsync(
`UPDATE forming_fields
SET clay_body = ?, clay_weight_value = ?, clay_weight_unit = ?, production_method = ?, dimensions = ?
WHERE step_id = ?`,
[
mergedForming.clayBody || null,
mergedForming.clayWeight?.value || null,
mergedForming.clayWeight?.unit || null,
mergedForming.productionMethod || null,
mergedForming.dimensions ? JSON.stringify(mergedForming.dimensions) : null,
id,
]
);
} else 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 = ?
SET cone = ?, temperature_value = ?, temperature_unit = ?, duration_minutes = ?, kiln_notes = ?, kiln_type = ?, kiln_custom_name = ?, kiln_position = ?, kiln_position_notes = ?, firing_schedule = ?
WHERE step_id = ?`,
[
mergedFiring.cone || null,
@ -185,23 +261,29 @@ export async function updateStep(id: string, updates: Partial<CreateStepParams>)
mergedFiring.temperature?.unit || null,
mergedFiring.durationMinutes || null,
mergedFiring.kilnNotes || null,
mergedFiring.kilnType || null,
mergedFiring.kilnCustomName || null,
mergedFiring.kilnPosition || null,
mergedFiring.kilnPositionNotes || null,
mergedFiring.firingSchedule || null,
id,
]
);
}
} else if (step.type === 'glazing' && updates.glazing) {
const existingGlazing = 'glazing' in step ? step.glazing : { glazeIds: [] };
const existingGlazing = 'glazing' in step ? step.glazing : { glazeIds: [], glazeLayers: [] };
const mergedGlazing = { ...existingGlazing, ...updates.glazing };
await db.runAsync(
`UPDATE glazing_fields
SET glaze_ids = ?, coats = ?, application = ?, mix_notes = ?
SET glaze_ids = ?, coats = ?, application = ?, mix_notes = ?, glaze_layers = ?
WHERE step_id = ?`,
[
JSON.stringify(mergedGlazing.glazeIds),
JSON.stringify(mergedGlazing.glazeIds || []),
mergedGlazing.coats || null,
mergedGlazing.application || null,
mergedGlazing.mixNotes || null,
JSON.stringify(mergedGlazing.glazeLayers),
id,
]
);

View File

@ -0,0 +1,86 @@
import { getDatabase } from '../index';
import { UserListItem, UserListType } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
export async function createListItem(
userId: string,
type: UserListType,
item: string,
notes?: string
): Promise<UserListItem> {
const db = getDatabase();
const id = generateUUID();
await db.runAsync(
`INSERT INTO user_lists (id, user_id, type, item, notes, is_completed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
[id, userId, type, item, notes || null, now()]
);
return {
id,
userId,
type,
item,
notes,
isCompleted: false,
createdAt: now(),
};
}
export async function getListItems(userId: string, type: UserListType): Promise<UserListItem[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM user_lists WHERE user_id = ? AND type = ? ORDER BY is_completed ASC, created_at DESC',
[userId, type]
);
return rows.map(row => ({
id: row.id,
userId: row.user_id,
type: row.type,
item: row.item,
notes: row.notes || undefined,
isCompleted: row.is_completed === 1,
createdAt: row.created_at,
}));
}
export async function updateListItem(id: string, updates: Partial<UserListItem>): Promise<void> {
const db = getDatabase();
if (updates.item !== undefined) {
await db.runAsync(
'UPDATE user_lists SET item = ? WHERE id = ?',
[updates.item, id]
);
}
if (updates.notes !== undefined) {
await db.runAsync(
'UPDATE user_lists SET notes = ? WHERE id = ?',
[updates.notes || null, id]
);
}
if (updates.isCompleted !== undefined) {
await db.runAsync(
'UPDATE user_lists SET is_completed = ? WHERE id = ?',
[updates.isCompleted ? 1 : 0, id]
);
}
}
export async function deleteListItem(id: string): Promise<void> {
const db = getDatabase();
await db.runAsync('DELETE FROM user_lists WHERE id = ?', [id]);
}
export async function toggleListItemCompleted(id: string): Promise<void> {
const db = getDatabase();
await db.runAsync(
'UPDATE user_lists SET is_completed = 1 - is_completed WHERE id = ?',
[id]
);
}

View File

@ -2,7 +2,7 @@
* Database schema definitions and migration scripts
*/
export const SCHEMA_VERSION = 7;
export const SCHEMA_VERSION = 8;
export const CREATE_TABLES = `
-- Projects table
@ -72,9 +72,33 @@ export const CREATE_TABLES = `
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')),
weight_unit TEXT NOT NULL DEFAULT 'lb' CHECK(weight_unit IN ('lb', 'oz', 'kg', 'g')),
analytics_opt_in INTEGER NOT NULL DEFAULT 0
);
-- User lists (shopping and wish lists)
CREATE TABLE IF NOT EXISTS user_lists (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('shopping', 'wish')),
item TEXT NOT NULL,
notes TEXT,
is_completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES settings(user_id)
);
-- Forming step fields (production phase)
CREATE TABLE IF NOT EXISTS forming_fields (
step_id TEXT PRIMARY KEY NOT NULL,
clay_body TEXT,
clay_weight_value REAL,
clay_weight_unit TEXT CHECK(clay_weight_unit IN ('lb', 'oz', 'kg', 'g')),
production_method TEXT CHECK(production_method IN ('wheel', 'handbuilding', 'casting', 'other')),
dimensions TEXT,
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
-- News/Tips cache (per user)
CREATE TABLE IF NOT EXISTS news_items (
id TEXT PRIMARY KEY NOT NULL,
@ -98,14 +122,18 @@ export const CREATE_TABLES = `
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);
CREATE INDEX IF NOT EXISTS idx_user_lists_user_id ON user_lists(user_id);
CREATE INDEX IF NOT EXISTS idx_user_lists_type ON user_lists(type);
`;
export const DROP_ALL_TABLES = `
DROP TABLE IF EXISTS firing_fields;
DROP TABLE IF EXISTS glazing_fields;
DROP TABLE IF EXISTS forming_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;
DROP TABLE IF EXISTS user_lists;
`;

View File

@ -1,42 +1,49 @@
/**
* Theme configuration for Pottery Diary
* Retro, vintage-inspired design with warm earth tones
* Modern, US-market optimized design with clear signaling colors
*/
export const colors = {
// Primary colors - warm vintage terracotta
primary: '#C17855', // Warm terracotta/clay
primaryLight: '#D4956F',
primaryDark: '#A5643E',
// Primary colors - Coral/Orange for buttons and accents
primary: '#E07A5F', // Coral/Orange accent
primaryLight: '#BF6B50', // Darker coral for cards/active states
primaryDark: '#C56548', // Darker coral for active states
// Backgrounds - vintage paper tones
background: '#F5EDE4', // Warm cream/aged paper
backgroundSecondary: '#EBE0D5', // Darker vintage beige
card: '#FAF6F1', // Soft off-white card
// Accent colors - Green for status indicators
accent: '#537A2F', // Green accent
accentLight: '#6B9A3E', // Lighter green
accentDark: '#3F5D24', // Darker green
// Text - warm vintage ink colors
text: '#3E2A1F', // Dark brown instead of black
textSecondary: '#8B7355', // Warm mid-brown
textTertiary: '#B59A7F', // Light warm brown
// Backgrounds - Dark brown theme
background: '#3D352E', // Main dark brown
backgroundSecondary: '#4F4640', // Card/Surface warm brown
card: '#4F4640', // Same as backgroundSecondary
// Borders - subtle warm tones
border: '#D4C4B0',
borderLight: '#E5D9C9',
// Text - Light on dark
text: '#D2CCC5', // Primary text (light warm)
textSecondary: '#BFC0C1', // Secondary text
textTertiary: '#959693', // Dimmed text/icons
buttonText: '#FFFFFF', // White text for primary buttons (better contrast)
// Status colors - muted retro palette
success: '#6B8E4E', // Muted olive green
warning: '#D4894F', // Warm amber
error: '#B85C50', // Muted terracotta red
info: '#5B8A9F', // Muted teal
// Borders - Subtle on dark backgrounds
border: '#6B6460', // Slightly lighter than card for visibility
borderLight: '#5A514B', // Chip background color
// 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
// Status colors
success: '#537A2F', // Green accent (same as accent)
successLight: '#6B9A3E', // Lighter green background
warning: '#E07A5F', // Use coral for warnings on dark bg
error: '#DC2626', // Keep red for errors (good contrast)
info: '#E07A5F', // Use coral for info
// Step type colors - Adjusted for dark backgrounds
forming: '#C89B7C', // Lighter clay tone
trimming: '#A89080', // Neutral tan
drying: '#D9C5A0', // Lighter sand
bisqueFiring: '#D67B59', // Coral variant
glazing: '#9B8B9B', // Lighter mauve
glazeFiring: '#E07A5F', // Coral (same as primary)
misc: '#959693', // Same as textTertiary
};
export const spacing = {
@ -82,23 +89,23 @@ export const borderRadius = {
export const shadows = {
sm: {
shadowColor: '#3E2A1F',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowOpacity: 0.25,
shadowRadius: 3,
elevation: 2,
},
md: {
shadowColor: '#3E2A1F',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 4,
},
lg: {
shadowColor: '#3E2A1F',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 5 },
shadowOpacity: 0.15,
shadowOpacity: 0.4,
shadowRadius: 10,
elevation: 6,
},

View File

@ -0,0 +1,24 @@
import { Dimensions, DimensionUnit } from '../../types';
export function formatDimensions(dims: Dimensions): string {
const parts: string[] = [];
if (dims.height) parts.push(`H: ${dims.height}`);
if (dims.width) parts.push(`W: ${dims.width}`);
if (dims.diameter) parts.push(`D: ${dims.diameter}`);
return parts.length > 0 ? `${parts.join(' × ')} ${dims.unit}` : '';
}
export function convertDimensionUnit(dims: Dimensions, toUnit: DimensionUnit): Dimensions {
if (dims.unit === toUnit) return dims;
const factor = toUnit === 'cm' ? 2.54 : 1 / 2.54;
return {
height: dims.height ? Math.round(dims.height * factor * 10) / 10 : undefined,
width: dims.width ? Math.round(dims.width * factor * 10) / 10 : undefined,
diameter: dims.diameter ? Math.round(dims.diameter * factor * 10) / 10 : undefined,
unit: toUnit,
notes: dims.notes,
};
}

View File

@ -4,3 +4,5 @@ export * from './uuid';
export * from './datetime';
export * from './stepOrdering';
export * from './colorMixing';
export * from './weightConversions';
export * from './dimensionUtils';

View File

@ -5,9 +5,9 @@ import { Step, StepType } from '../../types';
*/
const STEP_ORDER: StepType[] = [
'forming',
'trimming',
'drying',
'bisque_firing',
'trimming',
'glazing',
'glaze_firing',
'misc',

View File

@ -0,0 +1,55 @@
import { Weight, WeightUnit } from '../../types';
const CONVERSIONS = {
lb_to_oz: 16,
lb_to_kg: 0.453592,
oz_to_lb: 1 / 16,
oz_to_kg: 0.0283495,
kg_to_lb: 2.20462,
kg_to_g: 1000,
g_to_kg: 1 / 1000,
};
export function convertWeight(weight: Weight, toUnit: WeightUnit): Weight {
if (weight.unit === toUnit) return weight;
// Convert to kg as intermediate
let kg: number;
switch (weight.unit) {
case 'lb':
kg = weight.value * CONVERSIONS.lb_to_kg;
break;
case 'oz':
kg = weight.value * CONVERSIONS.oz_to_kg;
break;
case 'kg':
kg = weight.value;
break;
case 'g':
kg = weight.value * CONVERSIONS.g_to_kg;
break;
}
// Convert from kg to target
let targetValue: number;
switch (toUnit) {
case 'lb':
targetValue = kg * CONVERSIONS.kg_to_lb;
break;
case 'oz':
targetValue = kg * CONVERSIONS.kg_to_lb * CONVERSIONS.lb_to_oz;
break;
case 'kg':
targetValue = kg;
break;
case 'g':
targetValue = kg * CONVERSIONS.kg_to_g;
break;
}
return { value: Math.round(targetValue * 100) / 100, unit: toUnit };
}
export function formatWeight(weight: Weight): string {
return `${weight.value} ${weight.unit}`;
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Text, View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Text, View, StyleSheet, TouchableOpacity, ActivityIndicator, Image } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@ -9,13 +9,14 @@ import {
LoginScreen,
SignUpScreen,
OnboardingScreen,
HomeScreen,
ProjectsScreen,
ProjectDetailScreen,
StepEditorScreen,
NewsScreen,
SettingsScreen,
GlazePickerScreen,
GlazeMixerScreen,
UserListScreen,
} from '../screens';
import { colors, spacing } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
@ -23,11 +24,17 @@ import { useAuth } from '../contexts/AuthContext';
const RootStack = createNativeStackNavigator<RootStackParamList>();
const MainTab = createBottomTabNavigator<MainTabParamList>();
const homeIcon = require('../../assets/images/home_icon.png');
const projectIcon = require('../../assets/images/project_icon.png');
const tipsIcon = require('../../assets/images/tips_icon.png');
const settingsIcon = require('../../assets/images/settings_icon.png');
function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const tabs = [
{ name: 'Projects', label: 'Projects', icon: '🏺' },
{ name: 'News', label: 'Tips', icon: '💡' },
{ name: 'Settings', label: 'Settings', icon: '⚙️' },
{ name: 'Home', label: 'Home', iconType: 'image' as const, iconSource: homeIcon },
{ name: 'Projects', label: 'Projects', iconType: 'image' as const, iconSource: projectIcon },
{ name: 'News', label: 'Tips', iconType: 'image' as const, iconSource: tipsIcon },
{ name: 'Settings', label: 'Settings', iconType: 'image' as const, iconSource: settingsIcon },
];
return (
@ -62,7 +69,16 @@ function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
isFocused && isMiddle && styles.tabItemActiveMiddle,
]}
>
<Text style={styles.tabIcon}>{tab.icon}</Text>
<View style={styles.imageIconContainer}>
<Image
source={tab.iconSource}
style={[
styles.tabIconImage,
isFocused && styles.tabIconImageActive
]}
resizeMode="contain"
/>
</View>
<Text style={[
styles.tabLabel,
isFocused && styles.tabLabelActive
@ -83,7 +99,15 @@ function MainTabs() {
screenOptions={{
headerShown: false,
}}
initialRouteName="Home"
>
<MainTab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarLabel: 'Home',
}}
/>
<MainTab.Screen
name="Projects"
component={ProjectsScreen}
@ -113,7 +137,7 @@ const styles = StyleSheet.create({
tabBarContainer: {
flexDirection: 'row',
backgroundColor: colors.background,
height: 90,
height: 65,
position: 'absolute',
bottom: 20,
left: 10,
@ -129,12 +153,12 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingVertical: 8,
},
tabItemActive: {
backgroundColor: '#E8C7A8',
backgroundColor: colors.primaryLight,
borderWidth: 2,
borderColor: '#e6b98e',
borderColor: colors.primary,
},
tabItemActiveFirst: {
borderRadius: 25,
@ -145,12 +169,28 @@ const styles = StyleSheet.create({
tabItemActiveMiddle: {
borderRadius: 25,
},
tabIcon: {
fontSize: 32,
marginBottom: 4,
imageIconContainer: {
width: 40,
height: 40,
borderRadius: 0,
overflow: 'hidden',
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 2,
},
tabIconImage: {
width: 40,
height: 40,
opacity: 0.8,
backgroundColor: 'transparent',
},
tabIconImageActive: {
opacity: 1,
backgroundColor: 'transparent',
},
tabLabel: {
fontSize: 11,
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.3,
color: colors.textSecondary,
@ -234,9 +274,11 @@ export function AppNavigator() {
options={{ title: 'Select Glazes' }}
/>
<RootStack.Screen
name="GlazeMixer"
component={GlazeMixerScreen}
options={{ title: 'Mix Glazes' }}
name="UserList"
component={UserListScreen}
options={({ route }) => ({
title: route.params.type === 'shopping' ? 'Shopping List' : 'Wish List',
})}
/>
</>
)}

View File

@ -1,4 +1,6 @@
import { NavigatorScreenParams } from '@react-navigation/native';
import React from 'react';
import { GlazeLayer, UserListType } from '../types';
export type RootStackParamList = {
Login: undefined;
@ -6,14 +8,30 @@ export type RootStackParamList = {
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 };
StepEditor: {
projectId: string;
stepId?: string;
selectedGlazeIds?: string[]; // DEPRECATED - keep for backwards compat
newGlazeLayers?: GlazeLayer[]; // NEW
mixNotes?: string;
_timestamp?: number;
_editorKey?: string
};
GlazePicker: {
projectId: string;
stepId?: string;
selectedGlazeIds?: string[];
mode?: 'add_layer' | 'replace';
onGlazesSelected?: React.MutableRefObject<((layers: GlazeLayer[]) => void) | null>;
_editorKey?: string
};
GlazeDetail: { glazeId: string };
AddCustomGlaze: { onGlazeCreated?: (glazeId: string) => void };
UserList: { type: UserListType }; // NEW
};
export type MainTabParamList = {
Home: undefined;
Projects: undefined;
News: undefined;
Settings: undefined;

View File

@ -1,375 +0,0 @@
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, PotteryIcon } 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 ? (
<PotteryIcon
color={item.color}
finish={item.finish}
size={60}
/>
) : (
<View style={styles.colorPreview} />
)}
<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}>
<PotteryIcon
color={getMixedColor()}
finish="glossy"
size={80}
/>
<View style={styles.mixInfo}>
<Text style={styles.mixedGlazesText}>
{selectedGlazes.map(g => g.name).join(' + ')}
</Text>
<Text style={styles.glazeCount}>
{selectedGlazes.length} glaze{selectedGlazes.length !== 1 ? 's' : ''} selected
</Text>
</View>
</View>
<TextInput
style={styles.notesInput}
placeholder="Mix notes (e.g., '50/50', '3:1 ratio')"
value={mixNotes}
onChangeText={setMixNotes}
placeholderTextColor={colors.textSecondary}
/>
</Card>
</View>
)}
<Text style={styles.selectedCount}>
Select 2-3 glazes to mix ({selectedGlazes.length}/3)
</Text>
<FlatList
data={glazes}
renderItem={renderGlaze}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchQuery ? 'No glazes found' : 'No glazes in catalog'}
</Text>
</View>
}
/>
<View style={styles.footer}>
<Button
title="Save Mix"
onPress={handleCreateMix}
disabled={selectedGlazes.length < 2}
/>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.md,
backgroundColor: colors.background,
},
searchInput: {
backgroundColor: colors.backgroundSecondary,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: typography.fontSize.md,
color: colors.text,
},
mixPreviewContainer: {
position: 'absolute',
top: 60, // Below search header
left: 0,
right: 0,
zIndex: 500,
},
mixPreviewCard: {
marginHorizontal: spacing.md,
marginVertical: spacing.md,
backgroundColor: colors.background, // Solid background instead of transparent
borderWidth: 2,
borderColor: colors.primary,
},
mixPreviewTitle: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
mixPreviewContent: {
flexDirection: 'row',
gap: spacing.md,
marginBottom: spacing.md,
},
mixedColorPreview: {
width: 80,
height: 80,
borderRadius: borderRadius.md,
borderWidth: 3,
borderColor: colors.border,
},
mixInfo: {
flex: 1,
justifyContent: 'center',
},
mixedGlazesText: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.xs,
},
glazeCount: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
notesInput: {
backgroundColor: colors.background,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
color: colors.text,
},
selectedCount: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
textTransform: 'uppercase',
},
listContent: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl,
},
glazeCard: {
marginBottom: spacing.md,
},
glazeCardSelected: {
borderWidth: 3,
borderColor: colors.primary,
backgroundColor: colors.primaryLight + '20',
},
glazeMainContent: {
flexDirection: 'row',
gap: spacing.md,
},
colorPreview: {
width: 50,
height: 50,
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
},
glazeInfo: {
flex: 1,
},
glazeHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
glazeBrand: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
glazeName: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: spacing.xs,
},
glazeCode: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
emptyContainer: {
padding: spacing.xl,
alignItems: 'center',
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
},
footer: {
padding: spacing.md,
backgroundColor: colors.background,
borderTopWidth: 2,
borderTopColor: colors.border,
},
});

View File

@ -24,12 +24,13 @@ export const GlazePickerScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<RouteProps>();
const { user } = useAuth();
const { selectedGlazeIds = [] } = route.params || {};
const { selectedGlazeIds = [], mode = 'replace' } = route.params || {};
const [glazes, setGlazes] = useState<Glaze[]>([]);
const [selected, setSelected] = useState<string[]>(selectedGlazeIds);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadGlazes();
@ -76,21 +77,48 @@ export const GlazePickerScreen: React.FC = () => {
};
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);
if (isSaving) {
console.log('GlazePicker: Already saving, ignoring duplicate call');
return;
}
setIsSaving(true);
console.log('GlazePicker: Saving glazes and going back:', selected);
if (mode === 'add_layer') {
// Create GlazeLayer objects for each selected glaze
const newLayers = selected.map(glazeId => ({
glazeId,
application: 'brush' as const,
position: 'entire' as const,
coats: undefined,
notes: '',
}));
console.log('GlazePicker: Calling callback with layers:', newLayers);
// Call the callback directly if it exists
const callback = route.params.onGlazesSelected?.current;
if (callback) {
callback(newLayers);
}
// Simply go back - no params, no complex navigation
navigation.goBack();
} else {
// Existing behavior for backwards compatibility
navigation.navigate({
name: 'StepEditor',
params: {
projectId: route.params.projectId,
stepId: route.params.stepId,
selectedGlazeIds: selected,
_editorKey: route.params._editorKey, // Same key = same instance!
_editorKey: route.params._editorKey,
_timestamp: Date.now(),
},
merge: true, // Merge params with existing screen instead of creating new one
merge: true,
} as any);
}
};
const renderGlaze = ({ item }: { item: Glaze }) => {
@ -165,16 +193,6 @@ export const GlazePickerScreen: React.FC = () => {
/>
<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}
@ -311,7 +329,4 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.md,
},
mixButton: {
flex: 1,
},
});

423
src/screens/HomeScreen.tsx Normal file
View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Image,
} 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 { Project, Step } from '../types';
import { getAllProjects, getStepsByProject } from '../lib/db/repositories';
import { Card, PotteryIcon } from '../components';
import { colors, spacing, typography, borderRadius, stepTypeLabels } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
const settingsIcon = require('../../assets/images/settings_icon.png');
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const HomeScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { user } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [projectSteps, setProjectSteps] = useState<Record<string, Step[]>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
if (!user) return;
try {
const allProjects = await getAllProjects(user.id);
setProjects(allProjects);
// Load steps for each project
const stepsMap: Record<string, Step[]> = {};
for (const project of allProjects) {
try {
const steps = await getStepsByProject(project.id);
stepsMap[project.id] = steps;
} catch (error) {
console.error(`Failed to load steps for project ${project.id}:`, error);
stepsMap[project.id] = [];
}
}
setProjectSteps(stepsMap);
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
setLoading(false);
}
};
const getProjectStatus = (project: Project): string => {
if (project.status === 'done') {
return 'Completed';
}
// Get the most recent step for in-progress projects
const steps = projectSteps[project.id] || [];
if (steps.length === 0) {
return 'In Progress';
}
// Sort by updatedAt and get the most recent
const sortedSteps = [...steps].sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const latestStep = sortedSteps[0];
// Return the step type label
return stepTypeLabels[latestStep.type] || 'In Progress';
};
// Calculate stats
const totalProjects = projects.length;
const inProgressProjects = projects.filter(p => p.status === 'in_progress').length;
const completedProjects = projects.filter(p => p.status === 'completed').length;
// Get recent projects (last 3)
const recentProjects = projects
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 3);
const handleNewProject = () => {
navigation.navigate('MainTabs', { screen: 'Projects' });
};
const handleBrowseTips = () => {
navigation.navigate('MainTabs', { screen: 'News' });
};
const handleViewAllProjects = () => {
navigation.navigate('MainTabs', { screen: 'Projects' });
};
const handleProjectPress = (projectId: string) => {
navigation.navigate('ProjectDetail', { projectId });
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Top App Bar */}
<View style={styles.topBar}>
<View style={styles.greetingContainer}>
<Text style={styles.greetingText}>Welcome Back,</Text>
<Text style={styles.nameText}>{user?.name || 'Potter'}</Text>
</View>
<TouchableOpacity
style={styles.settingsButton}
onPress={() => navigation.navigate('MainTabs', { screen: 'Settings' })}
>
<Image
source={settingsIcon}
style={styles.settingsIconImage}
resizeMode="contain"
/>
</TouchableOpacity>
</View>
{/* Stats Section */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Total Projects</Text>
<Text style={styles.statNumber}>{totalProjects}</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>In Progress</Text>
<Text style={styles.statNumber}>{inProgressProjects}</Text>
</View>
</View>
{/* Button Group */}
<View style={styles.buttonGroup}>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleNewProject}
>
<Text style={styles.primaryButtonText}>New Pottery Project</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleBrowseTips}
>
<Text style={styles.secondaryButtonText}>Browse Pottery Tips</Text>
</TouchableOpacity>
</View>
{/* Section Header */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Recent Projects</Text>
{projects.length > 3 && (
<TouchableOpacity onPress={handleViewAllProjects}>
<Text style={styles.viewAllText}>View All</Text>
</TouchableOpacity>
)}
</View>
{/* List Items */}
<View style={styles.projectsList}>
{recentProjects.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No projects yet</Text>
<Text style={styles.emptySubtext}>Start creating your pottery journey!</Text>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleNewProject}
>
<Text style={styles.primaryButtonText}>Create First Project</Text>
</TouchableOpacity>
</View>
) : (
recentProjects.map((project) => (
<TouchableOpacity
key={project.id}
style={styles.projectItem}
onPress={() => handleProjectPress(project.id)}
>
<View style={styles.projectItemLeft}>
{project.coverImageUri ? (
<Image
source={{ uri: project.coverImageUri }}
style={styles.projectThumbnailImage}
resizeMode="cover"
/>
) : (
<View style={styles.projectThumbnail}>
<Text style={styles.projectEmoji}>🏺</Text>
</View>
)}
<View style={styles.projectInfo}>
<Text style={styles.projectName}>{project.title}</Text>
<Text style={styles.projectStatus}>
{getProjectStatus(project)}
</Text>
</View>
</View>
<View style={styles.projectItemRight}>
<Text style={styles.projectDate}>
{new Date(project.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: spacing.lg,
paddingBottom: 100,
},
topBar: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
paddingVertical: spacing.lg,
marginBottom: spacing.lg,
gap: spacing.lg,
},
greetingContainer: {
flex: 1,
},
greetingText: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.medium,
color: colors.textSecondary,
marginBottom: spacing.xs / 2,
},
nameText: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.text,
},
settingsButton: {
padding: spacing.xs,
marginLeft: spacing.md,
},
settingsIconImage: {
width: 32,
height: 32,
opacity: 0.8,
},
statsContainer: {
flexDirection: 'row',
gap: spacing.md,
marginBottom: spacing.xl,
},
statCard: {
flex: 1,
backgroundColor: colors.card,
padding: spacing.lg,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
},
statLabel: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
statNumber: {
fontSize: 32,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
},
buttonGroup: {
gap: spacing.md,
marginBottom: spacing.xl,
},
primaryButton: {
backgroundColor: colors.primaryLight,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
alignItems: 'center',
},
primaryButtonText: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.buttonText,
},
secondaryButton: {
backgroundColor: 'transparent',
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
borderWidth: 2,
borderColor: colors.accent,
alignItems: 'center',
},
secondaryButtonText: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semiBold,
color: colors.accent,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
sectionTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
},
viewAllText: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semiBold,
color: colors.primary,
},
projectsList: {
gap: spacing.md,
},
projectItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: colors.card,
padding: spacing.md,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
},
projectItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
projectThumbnail: {
width: 50,
height: 50,
borderRadius: borderRadius.md,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
projectThumbnailImage: {
width: 50,
height: 50,
borderRadius: borderRadius.md,
marginRight: spacing.md,
},
projectEmoji: {
fontSize: 24,
},
projectInfo: {
flex: 1,
},
projectName: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semiBold,
color: colors.text,
marginBottom: spacing.xs / 2,
},
projectStatus: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
projectItemRight: {
marginLeft: spacing.md,
},
projectDate: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xl * 2,
},
emptyText: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semiBold,
color: colors.text,
marginBottom: spacing.xs,
},
emptySubtext: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
});

View File

@ -7,13 +7,15 @@ import {
KeyboardAvoidingView,
Platform,
Alert,
Image,
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 { colors, spacing, typography, borderRadius } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Login'>;
@ -49,13 +51,24 @@ export const LoginScreen: React.FC = () => {
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<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>
<Text style={styles.title}>ClayLog</Text>
<Text style={styles.subtitle}>Your Personal Pottery Diary</Text>
</View>
{/* Hero Image */}
<View style={styles.heroContainer}>
<View style={styles.heroImagePlaceholder}>
<Text style={styles.heroEmoji}>🏺</Text>
</View>
</View>
{/* Form */}
<View style={styles.form}>
<Input
label="Email"
@ -77,21 +90,42 @@ export const LoginScreen: React.FC = () => {
autoComplete="password"
/>
<Button
title="Login"
<TouchableOpacity
style={styles.primaryButton}
onPress={handleLogin}
loading={loading}
style={styles.loginButton}
/>
disabled={loading}
>
<Text style={styles.primaryButtonText}>
{loading ? 'Logging in...' : 'Log In'}
</Text>
</TouchableOpacity>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
{/* Social Buttons */}
<View style={styles.socialButtons}>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialButtonText}>G</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialButtonText}>🍎</Text>
</TouchableOpacity>
</View>
{/* Sign Up Link */}
<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>
<Text style={styles.signupLink}>Create Account</Text>
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
@ -100,41 +134,101 @@ export const LoginScreen: React.FC = () => {
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
backgroundColor: colors.background,
},
container: {
flex: 1,
},
content: {
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,
marginBottom: spacing.xl,
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
fontSize: 48,
fontWeight: '500',
color: colors.text,
letterSpacing: -0.5,
marginBottom: spacing.xs,
letterSpacing: 1,
},
subtitle: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
letterSpacing: 0.5,
fontWeight: '400',
},
heroContainer: {
alignItems: 'center',
marginVertical: spacing.xl * 1.5,
},
heroImagePlaceholder: {
width: 200,
height: 200,
borderRadius: borderRadius.lg,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
},
heroEmoji: {
fontSize: 100,
},
form: {
width: '100%',
marginTop: spacing.lg,
},
loginButton: {
primaryButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
alignItems: 'center',
marginTop: spacing.md,
minHeight: 48,
justifyContent: 'center',
},
primaryButtonText: {
color: colors.buttonText,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
letterSpacing: 0.15,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.lg,
gap: spacing.md,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
dividerText: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
socialButtons: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.lg,
marginBottom: spacing.lg,
},
socialButton: {
width: 48,
height: 48,
borderRadius: 24,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
justifyContent: 'center',
alignItems: 'center',
},
socialButtonText: {
fontSize: 20,
},
signupContainer: {
flexDirection: 'row',
@ -142,12 +236,12 @@ const styles = StyleSheet.create({
marginTop: spacing.lg,
},
signupText: {
fontSize: typography.fontSize.md,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
signupLink: {
fontSize: typography.fontSize.md,
color: colors.primary,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.bold,
},
});

View File

@ -69,6 +69,174 @@ const POTTERY_TIPS: Tip[] = [
category: 'Forming',
icon: '📏',
},
{
id: '9',
title: 'Trimming Timing',
content: 'Trim when leather-hard (firm but not dry). Too wet = distorts, too dry = cracks.',
category: 'Trimming',
icon: '🔧',
},
{
id: '10',
title: 'Foot Rings',
content: 'Add a foot ring for stability and to elevate the piece from surfaces. Enhances the final look!',
category: 'Trimming',
icon: '⭕',
},
{
id: '11',
title: 'Reclaim Clay',
content: 'Save all scraps! Dry completely, crush, add water, and wedge thoroughly to reuse. Nothing goes to waste.',
category: 'Recycling',
icon: '♻️',
},
{
id: '12',
title: 'Never Mix Clay Bodies',
content: 'Keep different clay types separate to avoid firing issues. Different clays shrink at different rates!',
category: 'Recycling',
icon: '⚠️',
},
{
id: '13',
title: 'Wire Tool',
content: 'Use a twisted wire for cutting clay. Keep it taut for clean cuts and remove pieces from the wheel.',
category: 'Tools',
icon: '✂️',
},
{
id: '14',
title: 'Sponge Control',
content: 'Don\'t over-sponge! Too much water weakens your piece and can cause collapse. Light touch is key.',
category: 'Tools',
icon: '🧽',
},
{
id: '15',
title: 'Underglaze vs Glaze',
content: 'Underglazes go on greenware or bisque, glazes only on bisque. Knowing the difference prevents mistakes!',
category: 'Surface',
icon: '🖌️',
},
{
id: '16',
title: 'Sgraffito Timing',
content: 'Scratch through slip or underglaze when leather-hard for crisp, clean lines. Sharp tools work best!',
category: 'Surface',
icon: '✏️',
},
{
id: '17',
title: 'Compression',
content: 'Compress the bottom of thrown pieces to prevent cracking. Press firmly while centering to strengthen clay.',
category: 'Forming',
icon: '💪',
},
{
id: '18',
title: 'S-Cracks Prevention',
content: 'Pull from center outward when throwing to avoid S-cracks in the bottom. Consistent technique is crucial!',
category: 'Forming',
icon: '🌀',
},
{
id: '19',
title: 'Silica Dust Safety',
content: 'Always wet-clean clay dust. Never sweep dry - silica dust is harmful to lungs! Your health matters.',
category: 'Safety',
icon: '🫁',
},
{
id: '20',
title: 'Ventilation',
content: 'Ensure good airflow when glazing and firing. Fumes can be toxic. Open windows or use exhaust fans!',
category: 'Safety',
icon: '🌬️',
},
{
id: '21',
title: 'Centering is Key',
content: 'Take your time centering. A well-centered piece prevents wobbling and uneven walls throughout the throwing process.',
category: 'Basics',
icon: '🎯',
},
{
id: '22',
title: 'Coning Up & Down',
content: 'Cone clay up and down 2-3 times to align clay particles and strengthen the clay body before forming.',
category: 'Basics',
icon: '🔄',
},
{
id: '23',
title: 'Slow Bisque Start',
content: 'Start bisque firing slowly (150°F/hr to 400°F) to allow water to escape without cracking your pieces.',
category: 'Firing',
icon: '🐌',
},
{
id: '24',
title: 'Pyrometric Cones',
content: 'Always use witness cones! Digital controllers can fail. Cones don\'t lie about actual heatwork achieved.',
category: 'Firing',
icon: '🎗️',
},
{
id: '25',
title: 'Kiln Loading',
content: 'Stack bisque with 1" spacing for proper airflow. Touching pieces can stick together or crack during firing.',
category: 'Firing',
icon: '📦',
},
{
id: '26',
title: 'Glaze Thickness Test',
content: 'Dip a finger in wet glaze - you should see skin tone through it. Too opaque means it\'s too thick!',
category: 'Glazing',
icon: '👆',
},
{
id: '27',
title: 'Wax Resist',
content: 'Wax the bottom of pieces before glazing to prevent them from sticking to kiln shelves. Essential step!',
category: 'Glazing',
icon: '🕯️',
},
{
id: '28',
title: 'Stir Your Glaze',
content: 'Always stir glazes thoroughly before use! Glaze settles and consistency directly affects color and texture.',
category: 'Glazing',
icon: '🥄',
},
{
id: '29',
title: 'Clay Storage',
content: 'Store clay in airtight containers or wrap tightly in plastic. Add a damp towel for extra moisture retention.',
category: 'Best Practice',
icon: '📦',
},
{
id: '30',
title: 'Clean Water',
content: 'Change throwing water frequently. Dirty water contaminates your clay with sediment and weakens it.',
category: 'Best Practice',
icon: '💧',
},
{
id: '31',
title: 'Handle Attachment',
content: 'Attach handles to leather-hard pieces. Score surfaces, apply slip, and compress joints firmly for strength.',
category: 'Forming',
icon: '🤝',
},
{
id: '32',
title: 'Warping Prevention',
content: 'Dry thick and thin sections at the same rate by covering thinner areas with plastic. Prevents warping!',
category: 'Drying',
icon: '🛡️',
},
];
export const NewsScreen: React.FC = () => {
@ -140,11 +308,9 @@ export const NewsScreen: React.FC = () => {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: spacing.lg,
backgroundColor: colors.background,
borderBottomWidth: 3,
@ -156,11 +322,13 @@ const styles = StyleSheet.create({
color: colors.primary,
letterSpacing: 0.5,
textTransform: 'uppercase',
paddingHorizontal: spacing.lg,
},
headerSubtitle: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
paddingHorizontal: spacing.lg,
},
filterContainer: {
paddingVertical: spacing.md,

View File

@ -63,7 +63,9 @@ export const ProjectDetailScreen: React.FC = () => {
const loadSteps = async () => {
try {
const projectSteps = await getStepsByProject(route.params.projectId);
const sortedSteps = sortStepsByLogicalOrder(projectSteps);
const sortedSteps = [...projectSteps].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setSteps(sortedSteps);
console.log('✅ Steps reloaded, count:', sortedSteps.length);
} catch (error) {
@ -81,7 +83,9 @@ export const ProjectDetailScreen: React.FC = () => {
setCoverImage(project.coverImageUri || null);
const projectSteps = await getStepsByProject(project.id);
const sortedSteps = sortStepsByLogicalOrder(projectSteps);
const sortedSteps = [...projectSteps].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setSteps(sortedSteps);
}
} catch (error) {
@ -252,8 +256,8 @@ export const ProjectDetailScreen: React.FC = () => {
<TouchableOpacity
style={[
styles.statusButton,
status === 'in_progress' && styles.statusButtonActive,
styles.statusButtonInProgress,
status === 'in_progress' && styles.statusButtonActive,
]}
onPress={() => setStatus('in_progress')}
>
@ -267,8 +271,8 @@ export const ProjectDetailScreen: React.FC = () => {
<TouchableOpacity
style={[
styles.statusButton,
status === 'done' && styles.statusButtonActive,
styles.statusButtonDone,
status === 'done' && styles.statusButtonActive,
]}
onPress={() => setStatus('done')}
>
@ -286,15 +290,6 @@ export const ProjectDetailScreen: React.FC = () => {
onPress={handleSave}
loading={saving}
/>
{!isNew && (
<Button
title="Delete Project"
onPress={handleDelete}
variant="outline"
style={styles.deleteButton}
/>
)}
</Card>
{!isNew && (
@ -367,6 +362,13 @@ export const ProjectDetailScreen: React.FC = () => {
))}
</View>
)}
<Button
title="Delete Project"
onPress={handleDelete}
variant="outline"
style={styles.deleteButton}
/>
</>
)}
</KeyboardAwareScrollView>
@ -377,14 +379,13 @@ export const ProjectDetailScreen: React.FC = () => {
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
backgroundColor: colors.background,
},
container: {
flex: 1,
},
content: {
paddingHorizontal: spacing.md,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
},
centerContainer: {
@ -539,26 +540,27 @@ const styles = StyleSheet.create({
backgroundColor: colors.backgroundSecondary,
},
statusButtonActive: {
borderWidth: 3,
borderWidth: 4,
},
statusButtonInProgress: {
borderColor: '#E8A87C',
borderColor: colors.primary,
},
statusButtonDone: {
borderColor: '#85CDCA',
borderColor: colors.success,
},
statusButtonArchived: {
borderColor: '#C38D9E',
borderColor: colors.textTertiary,
},
statusButtonText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
color: colors.textTertiary,
letterSpacing: 0.2,
textTransform: 'uppercase',
},
statusButtonTextActive: {
color: colors.text,
fontWeight: '900',
},
suggestionCard: {
marginBottom: spacing.md,

View File

@ -1,11 +1,10 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
Image,
RefreshControl,
TextInput,
} from 'react-native';
@ -14,9 +13,8 @@ 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 { Button, ProjectCard, EmptyState } 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>;
@ -113,54 +111,13 @@ export const ProjectsScreen: React.FC = () => {
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}`}
<ProjectCard
project={item}
onPress={handleProjectPress}
variant="grid"
showStepCount
stepCount={stepCount}
/>
{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>
);
};
@ -175,29 +132,26 @@ export const ProjectsScreen: React.FC = () => {
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>
<Button
title="New Project"
onPress={handleCreateProject}
size="md"
accessibilityLabel="Create new project"
style={styles.emptyButton}
<EmptyState
icon="🔍"
title="No matches found"
description="Try a different search term"
/>
) : projects.length === 0 ? (
<EmptyState
icon="🏺"
title="No projects yet"
description="Start your first piece!"
actionLabel="New Project"
onAction={handleCreateProject}
/>
</View>
) : (
<FlatList
data={filteredProjects}
renderItem={renderProject}
keyExtractor={(item) => item.id}
numColumns={2}
columnWrapperStyle={styles.columnWrapper}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
@ -214,7 +168,7 @@ export const ProjectsScreen: React.FC = () => {
<Button
title="New Project"
onPress={handleCreateProject}
size="sm"
size="md"
accessibilityLabel="Create new project"
/>
</View>
@ -306,6 +260,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.md,
},
searchInput: {
@ -314,9 +269,9 @@ const styles = StyleSheet.create({
borderRadius: borderRadius.md,
borderWidth: 2,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: typography.fontSize.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
fontSize: typography.fontSize.sm,
color: colors.text,
fontWeight: typography.fontWeight.medium,
},
@ -368,131 +323,9 @@ const styles = StyleSheet.create({
listContent: {
paddingBottom: 100, // Extra space for floating tab bar
},
projectCard: {
marginHorizontal: spacing.md,
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,
columnWrapper: {
gap: spacing.md,
paddingHorizontal: spacing.lg,
},
});

View File

@ -1,13 +1,19 @@
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 { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { getSettings, updateSettings, getAllProjects, getStepsByProject, getListItems } 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';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const SettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { user, signOut } = useAuth();
const [settings, setSettings] = useState<Settings>({
unitSystem: 'imperial',
@ -51,10 +57,15 @@ export const SettingsScreen: React.FC = () => {
try {
const projects = await getAllProjects(user.id);
const shoppingList = await getListItems(user.id, 'shopping');
const wishList = await getListItems(user.id, 'wish');
const exportData: any = {
exportDate: new Date().toISOString(),
version: '1.0.0',
projects: [],
shoppingList,
wishList,
};
for (const project of projects) {
@ -141,6 +152,49 @@ export const SettingsScreen: React.FC = () => {
ios_backgroundColor={colors.border}
/>
</View>
<View style={[styles.setting, styles.noBorder]}>
<View>
<Text style={styles.settingLabel}>Weight Unit</Text>
<Text style={styles.settingValue}>
{settings.weightUnit === 'lb' ? 'Pounds (lb)' :
settings.weightUnit === 'oz' ? 'Ounces (oz)' :
settings.weightUnit === 'kg' ? 'Kilograms (kg)' : 'Grams (g)'}
</Text>
</View>
<Switch
value={settings.weightUnit === 'kg' || settings.weightUnit === 'g'}
onValueChange={(value) => {
// Toggle between imperial (lb) and metric (kg)
const newUnit = value ? 'kg' : 'lb';
handleToggle('weightUnit', newUnit);
}}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={(settings.weightUnit === 'kg' || settings.weightUnit === 'g') ? colors.background : colors.background}
ios_backgroundColor={colors.border}
/>
</View>
</Card>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>My Lists</Text>
<Button
title="Shopping List"
onPress={() => navigation.navigate('UserList', { type: 'shopping' })}
variant="outline"
/>
<Button
title="Wish List"
onPress={() => navigation.navigate('UserList', { type: 'wish' })}
variant="outline"
style={styles.listButton}
/>
<Text style={styles.exportNote}>
Keep track of supplies you need to buy or items you want to acquire
</Text>
</Card>
<Card style={styles.card}>
@ -284,6 +338,9 @@ const styles = StyleSheet.create({
logoutButton: {
marginTop: spacing.md,
},
listButton: {
marginTop: spacing.sm,
},
noBorder: {
borderBottomWidth: 0,
},

View File

@ -13,8 +13,8 @@ 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 { Input } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'SignUp'>;
@ -72,19 +72,56 @@ export const SignUpScreen: React.FC = () => {
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<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>
<Text style={styles.title}>ClayLog</Text>
<Text style={styles.subtitle}>Your Personal Pottery Diary</Text>
</View>
{/* Hero Image */}
<View style={styles.heroContainer}>
<View style={styles.heroImagePlaceholder}>
<Text style={styles.heroEmoji}>🏺</Text>
</View>
</View>
{/* Form */}
<View style={styles.form}>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleSignUp}
disabled={loading}
>
<Text style={styles.primaryButtonText}>
{loading ? 'Creating Account...' : 'Create Account'}
</Text>
</TouchableOpacity>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
{/* Social Buttons */}
<View style={styles.socialButtons}>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialButtonText}>G</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialButtonText}>🍎</Text>
</TouchableOpacity>
</View>
{/* Input Fields */}
<Input
label="Name"
value={name}
onChangeText={setName}
placeholder="Your name"
placeholder="Your Name"
autoCapitalize="words"
autoComplete="name"
/>
@ -103,33 +140,27 @@ export const SignUpScreen: React.FC = () => {
label="Password"
value={password}
onChangeText={setPassword}
placeholder="At least 6 characters"
placeholder="Create a password"
secureTextEntry
autoCapitalize="none"
autoComplete="password-new"
autoComplete="password"
/>
<Input
label="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Re-enter password"
placeholder="Confirm your password"
secureTextEntry
autoCapitalize="none"
autoComplete="password-new"
/>
<Button
title="Sign Up"
onPress={handleSignUp}
loading={loading}
style={styles.signUpButton}
autoComplete="password"
/>
{/* Login Link */}
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.loginLink}>Login</Text>
<TouchableOpacity onPress={() => navigation.navigate('Login')}>
<Text style={styles.loginLink}>Log In</Text>
</TouchableOpacity>
</View>
</View>
@ -142,7 +173,7 @@ export const SignUpScreen: React.FC = () => {
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.backgroundSecondary,
backgroundColor: colors.background,
},
container: {
flex: 1,
@ -151,33 +182,90 @@ const styles = StyleSheet.create({
flexGrow: 1,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.xl,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xl * 2,
},
logo: {
fontSize: 80,
marginBottom: spacing.md,
marginBottom: spacing.xl,
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: typography.fontWeight.bold,
color: colors.primary,
fontSize: 48,
fontWeight: '500',
color: colors.text,
letterSpacing: -0.5,
marginBottom: spacing.xs,
letterSpacing: 1,
},
subtitle: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
letterSpacing: 0.5,
fontWeight: '400',
},
heroContainer: {
alignItems: 'center',
marginVertical: spacing.xl,
},
heroImagePlaceholder: {
width: 180,
height: 180,
borderRadius: borderRadius.lg,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
},
heroEmoji: {
fontSize: 90,
},
form: {
width: '100%',
},
signUpButton: {
marginTop: spacing.md,
primaryButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
alignItems: 'center',
marginBottom: spacing.md,
minHeight: 48,
justifyContent: 'center',
},
primaryButtonText: {
color: colors.buttonText,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
letterSpacing: 0.15,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.md,
gap: spacing.md,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
dividerText: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
socialButtons: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.lg,
marginBottom: spacing.xl,
},
socialButton: {
width: 48,
height: 48,
borderRadius: 24,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
justifyContent: 'center',
alignItems: 'center',
},
socialButtonText: {
fontSize: 20,
},
loginContainer: {
flexDirection: 'row',
@ -185,12 +273,12 @@ const styles = StyleSheet.create({
marginTop: spacing.lg,
},
loginText: {
fontSize: typography.fontSize.md,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
loginLink: {
fontSize: typography.fontSize.md,
color: colors.primary,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.bold,
},
});

View File

@ -1,14 +1,26 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Alert, Image, TouchableOpacity, FlatList } from 'react-native';
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Alert, Image, TouchableOpacity, FlatList, TextInput, findNodeHandle } 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 {
StepType,
WeightUnit,
Weight,
ProductionMethod,
DimensionUnit,
Dimensions,
KilnType,
KilnPosition,
GlazeLayer,
GlazePosition,
ApplicationMethod
} 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 { Button, Input, Card, Picker, WeightInput, ButtonGrid, DimensionsInput, GlazeLayerCard } from '../components';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { useAuth } from '../contexts/AuthContext';
@ -17,9 +29,9 @@ 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: 'trimming', label: '🔧 Trimming' },
{ value: 'glazing', label: '🎨 Glazing' },
{ value: 'glaze_firing', label: '⚡ Glaze Firing' },
{ value: 'misc', label: '📝 Misc' },
@ -35,33 +47,66 @@ export const StepEditorScreen: React.FC = () => {
// Create unique editor key for this screen instance
const editorKey = React.useRef(route.params._editorKey || `editor-${Date.now()}`).current;
// Refs for scrolling to glaze layers section
const scrollViewRef = useRef<KeyboardAwareScrollView>(null);
const glazeLayersSectionRef = useRef<View>(null);
const [stepType, setStepType] = useState<StepType>('forming');
const [notes, setNotes] = useState('');
const [photos, setPhotos] = useState<string[]>([]);
// User preferences
const [tempUnit, setTempUnit] = useState<'F' | 'C'>('F');
const [weightUnit, setWeightUnit] = useState<WeightUnit>('lb');
const [dimensionUnit, setDimensionUnit] = useState<DimensionUnit>('in');
// Forming fields
const [clayBody, setClayBody] = useState('');
const [clayWeight, setClayWeight] = useState<Weight | undefined>(undefined);
const [productionMethod, setProductionMethod] = useState<ProductionMethod>('wheel');
const [dimensions, setDimensions] = useState<Dimensions | undefined>(undefined);
// Firing fields (existing + new)
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 [kilnType, setKilnType] = useState<KilnType | ''>('');
const [kilnCustomName, setKilnCustomName] = useState('');
const [kilnPosition, setKilnPosition] = useState<KilnPosition | ''>('');
const [kilnPositionNotes, setKilnPositionNotes] = useState('');
const [firingSchedule, setFiringSchedule] = useState('');
// Glazing fields (multi-layer)
const [glazeLayers, setGlazeLayers] = useState<GlazeLayer[]>([]);
const [customGlazeName, setCustomGlazeName] = useState('');
// Legacy glazing fields (for backwards compatibility)
const [coats, setCoats] = useState('2');
const [applicationMethod, setApplicationMethod] = useState<'brush' | 'dip' | 'spray' | 'pour' | 'other'>('brush');
const [applicationMethod, setApplicationMethod] = useState<ApplicationMethod>('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
// Track if we're adding glazes from catalog (new flow)
const [isAddingFromCatalog, setIsAddingFromCatalog] = useState(false);
// Load user's preferences
useEffect(() => {
loadUserTempPreference();
loadUserPreferences();
}, [user]);
const loadUserTempPreference = async () => {
const loadUserPreferences = async () => {
if (!user) return;
try {
const settings = await getSettings(user.id);
setTempUnit(settings.tempUnit);
setWeightUnit(settings.weightUnit);
setDimensionUnit(settings.unitSystem === 'metric' ? 'cm' : 'in');
} catch (error) {
console.error('Failed to load temp preference:', error);
console.error('Failed to load user preferences:', error);
}
};
@ -71,21 +116,24 @@ export const StepEditorScreen: React.FC = () => {
}
}, [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]);
// Callback ref for receiving glaze selection from GlazePicker
// This is a clean, direct approach without route params or useEffects
const glazeSelectionCallbackRef = useRef<((layers: GlazeLayer[]) => void) | null>(null);
// TEMPORARILY DISABLED - Auto-save conflicts with new glaze layer flow
// This useEffect was causing immediate navigation.reset() back to ProjectDetail
// when adding glazes from catalog, preventing users from editing glaze layers.
// TODO: Re-enable with proper flow detection once new glaze flow is stable.
/*
// Also watch for route param changes directly and auto-save
useEffect(() => {
// Skip auto-save if we're in the new add_layer flow
// Check both the flag AND if we just processed newGlazeLayers
if (isAddingFromCatalog || (route.params && 'newGlazeLayers' in route.params)) {
console.log('Skipping auto-save - user is adding from catalog or new layers present');
return;
}
let hasChanges = false;
if (route.params && 'selectedGlazeIds' in route.params && route.params.selectedGlazeIds) {
@ -176,6 +224,7 @@ export const StepEditorScreen: React.FC = () => {
}, 100);
}
}, [route.params?.selectedGlazeIds, route.params?.mixNotes]);
*/
// Auto-save whenever any field changes (but only for existing steps)
useEffect(() => {
@ -196,16 +245,29 @@ export const StepEditorScreen: React.FC = () => {
photoUris: photos,
};
if (stepType === 'bisque_firing' || stepType === 'glaze_firing') {
if (stepType === 'forming') {
stepData.forming = {
clayBody: clayBody || undefined,
clayWeight: clayWeight,
productionMethod: productionMethod,
dimensions: dimensions,
};
} else 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,
kilnType: kilnType || undefined,
kilnCustomName: kilnCustomName || undefined,
kilnPosition: kilnPosition || undefined,
kilnPositionNotes: kilnPositionNotes || undefined,
firingSchedule: firingSchedule || undefined,
};
} else if (stepType === 'glazing') {
stepData.glazing = {
glazeIds: selectedGlazeIds,
glazeLayers: glazeLayers.length > 0 ? glazeLayers : undefined,
glazeIds: selectedGlazeIds.length > 0 ? selectedGlazeIds : undefined,
coats: coats ? parseInt(coats) : undefined,
application: applicationMethod,
mixNotes: mixNotes || undefined,
@ -225,14 +287,28 @@ export const StepEditorScreen: React.FC = () => {
stepType,
notes,
photos,
// Forming fields
clayBody,
clayWeight,
productionMethod,
dimensions,
// Firing fields
cone,
temperature,
duration,
kilnNotes,
kilnType,
kilnCustomName,
kilnPosition,
kilnPositionNotes,
firingSchedule,
// Glazing fields
glazeLayers,
coats,
applicationMethod,
selectedGlazeIds,
mixNotes,
// Meta
isEditing,
stepId,
loading,
@ -251,20 +327,39 @@ export const StepEditorScreen: React.FC = () => {
setNotes(step.notesMarkdown || '');
setPhotos(step.photoUris || []);
if (step.type === 'bisque_firing' || step.type === 'glaze_firing') {
if (step.type === 'forming') {
const forming = (step as any).forming;
if (forming) {
setClayBody(forming.clayBody || '');
setClayWeight(forming.clayWeight);
setProductionMethod(forming.productionMethod || 'wheel');
setDimensions(forming.dimensions);
}
} else 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 || '');
setKilnType(firing.kilnType || '');
setKilnCustomName(firing.kilnCustomName || '');
setKilnPosition(firing.kilnPosition || '');
setKilnPositionNotes(firing.kilnPositionNotes || '');
setFiringSchedule(firing.firingSchedule || '');
}
} else if (step.type === 'glazing') {
const glazing = (step as any).glazing;
if (glazing) {
// New multi-layer format
if (glazing.glazeLayers && glazing.glazeLayers.length > 0) {
setGlazeLayers(glazing.glazeLayers);
} else {
// Legacy format - keep for compatibility
setCoats(glazing.coats?.toString() || '2');
setApplicationMethod(glazing.application || 'brush');
setSelectedGlazeIds(glazing.glazeIds || []);
}
setMixNotes(glazing.mixNotes || '');
}
}
@ -337,6 +432,39 @@ export const StepEditorScreen: React.FC = () => {
setPhotos(photos.filter((_, i) => i !== index));
};
const handleAddFromCatalog = () => {
console.log('Add from Catalog clicked - setting up callback and navigating to GlazePicker');
// Set up callback BEFORE navigating - this will be called directly by GlazePicker
glazeSelectionCallbackRef.current = (newLayers: GlazeLayer[]) => {
console.log('Callback received layers:', newLayers);
setGlazeLayers(prevLayers => [...prevLayers, ...newLayers]);
if (stepType !== 'glazing') {
console.log('Setting stepType to glazing');
setStepType('glazing');
}
// Scroll to glaze layers section after state update
setTimeout(() => {
if (glazeLayersSectionRef.current && scrollViewRef.current) {
glazeLayersSectionRef.current.measure((x, y, width, height, pageX, pageY) => {
if (pageY) {
scrollViewRef.current?.scrollToPosition(0, Math.max(0, pageY - 100), true);
}
});
}
}, 200);
};
navigation.navigate('GlazePicker', {
projectId,
stepId,
mode: 'add_layer',
onGlazesSelected: glazeSelectionCallbackRef,
});
};
const handleSave = async () => {
setSaving(true);
try {
@ -347,16 +475,30 @@ export const StepEditorScreen: React.FC = () => {
photoUris: photos,
};
if (stepType === 'bisque_firing' || stepType === 'glaze_firing') {
if (stepType === 'forming') {
stepData.forming = {
clayBody: clayBody || undefined,
clayWeight: clayWeight,
productionMethod: productionMethod,
dimensions: dimensions,
};
} else 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,
kilnType: kilnType || undefined,
kilnCustomName: kilnCustomName || undefined,
kilnPosition: kilnPosition || undefined,
kilnPositionNotes: kilnPositionNotes || undefined,
firingSchedule: firingSchedule || undefined,
};
} else if (stepType === 'glazing') {
stepData.glazing = {
glazeIds: selectedGlazeIds,
glazeLayers: glazeLayers.length > 0 ? glazeLayers : undefined,
// Legacy fields for backwards compatibility
glazeIds: selectedGlazeIds.length > 0 ? selectedGlazeIds : undefined,
coats: coats ? parseInt(coats) : undefined,
application: applicationMethod,
mixNotes: mixNotes || undefined,
@ -414,7 +556,64 @@ export const StepEditorScreen: React.FC = () => {
);
};
const renderFiringFields = () => (
const renderFormingFields = () => {
const productionMethods = [
{ value: 'wheel', label: 'Wheel' },
{ value: 'handbuilding', label: 'Handbuilding' },
{ value: 'casting', label: 'Casting' },
{ value: 'other', label: 'Other' },
];
return (
<>
<Input
label="Clay Body"
value={clayBody}
onChangeText={setClayBody}
placeholder="e.g., B-Mix, Porcelain"
/>
<WeightInput
label="Clay Weight"
value={clayWeight}
onChange={setClayWeight}
userPreferredUnit={weightUnit}
/>
<Text style={styles.label}>Production Method</Text>
<ButtonGrid
options={productionMethods}
selectedValue={productionMethod}
onSelect={(value) => setProductionMethod(value as ProductionMethod)}
columns={2}
/>
<DimensionsInput
label="Dimensions"
value={dimensions}
onChange={setDimensions}
userPreferredUnit={dimensionUnit}
/>
</>
);
};
const renderFiringFields = () => {
const kilnTypes = [
{ value: 'electric', label: 'Electric' },
{ value: 'gas', label: 'Gas' },
{ value: 'raku', label: 'Raku' },
{ value: 'other', label: 'Other' },
];
const kilnPositions = [
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'other', label: 'Other' },
];
return (
<>
<Input
label="Cone"
@ -436,73 +635,142 @@ export const StepEditorScreen: React.FC = () => {
placeholder="Total firing time"
keyboardType="numeric"
/>
<Text style={styles.label}>Kiln Type</Text>
<ButtonGrid
options={kilnTypes}
selectedValue={kilnType}
onSelect={(value) => setKilnType(value as KilnType)}
columns={2}
/>
{kilnType === 'other' && (
<Input
label="Custom Kiln Name"
value={kilnCustomName}
onChangeText={setKilnCustomName}
placeholder="Enter kiln name"
/>
)}
<Text style={styles.label}>Position in Kiln</Text>
<ButtonGrid
options={kilnPositions}
selectedValue={kilnPosition}
onSelect={(value) => setKilnPosition(value as KilnPosition)}
columns={2}
/>
{kilnPosition === 'other' && (
<Input
label="Position Notes"
value={kilnPositionNotes}
onChangeText={setKilnPositionNotes}
placeholder="Describe position"
/>
)}
<Input
label="Firing Schedule"
value={firingSchedule}
onChangeText={setFiringSchedule}
placeholder="Ramp, holds, cooling, etc."
multiline
numberOfLines={4}
/>
<Input
label="Kiln Notes"
value={kilnNotes}
onChangeText={setKilnNotes}
placeholder="Kiln type, ramp, etc."
placeholder="Additional notes"
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: '✨' },
];
const handleAddCustomGlaze = () => {
if (!customGlazeName.trim()) return;
const newLayer: GlazeLayer = {
customGlazeName: customGlazeName.trim(),
application: 'brush',
position: 'entire',
coats: 2,
notes: '',
};
setGlazeLayers([...glazeLayers, newLayer]);
setCustomGlazeName('');
};
const handleUpdateLayer = (index: number, updatedLayer: GlazeLayer) => {
const newLayers = [...glazeLayers];
newLayers[index] = updatedLayer;
setGlazeLayers(newLayers);
};
const handleRemoveLayer = (index: number) => {
setGlazeLayers(glazeLayers.filter((_, i) => i !== index));
};
return (
<>
<Input
label="Number of Coats"
value={coats}
onChangeText={setCoats}
placeholder="2"
keyboardType="numeric"
/>
<View ref={glazeLayersSectionRef}>
<Text style={styles.label}>Glaze Layers</Text>
<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>
{glazeLayers.map((layer, index) => (
<GlazeLayerCard
key={index}
layer={layer}
layerNumber={index + 1}
onUpdate={(updated) => handleUpdateLayer(index, updated)}
onRemove={() => handleRemoveLayer(index)}
/>
))}
</View>
<View style={styles.addGlazeContainer}>
<Text style={styles.label}>Add Custom Glaze</Text>
<View style={styles.customGlazeRow}>
<TextInput
style={styles.customGlazeInput}
value={customGlazeName}
onChangeText={setCustomGlazeName}
placeholder="Enter glaze name"
placeholderTextColor={colors.textSecondary}
/>
<Button
title={`Select Glazes (${selectedGlazeIds.length} selected)`}
onPress={() => navigation.navigate('GlazePicker', {
projectId,
stepId,
selectedGlazeIds,
_editorKey: editorKey,
})}
title="Add"
onPress={handleAddCustomGlaze}
variant="primary"
size="sm"
/>
</View>
</View>
<Button
title="Add from Catalog"
onPress={handleAddFromCatalog}
variant="outline"
style={styles.glazePickerButton}
style={styles.catalogButton}
/>
<Input
label="Mix Notes"
value={mixNotes}
onChangeText={setMixNotes}
placeholder="Notes about glaze mixing or combination"
multiline
numberOfLines={3}
/>
<Text style={styles.note}>
{selectedGlazeIds.length === 0
? 'Tap above to select glazes from catalog'
: `${selectedGlazeIds.length} glaze${selectedGlazeIds.length > 1 ? 's' : ''} selected`}
{glazeLayers.length === 0
? 'Add glaze layers using custom names or from catalog'
: `${glazeLayers.length} layer${glazeLayers.length > 1 ? 's' : ''} added`}
</Text>
</>
);
@ -510,6 +778,7 @@ export const StepEditorScreen: React.FC = () => {
return (
<KeyboardAwareScrollView
ref={scrollViewRef}
style={styles.container}
contentContainerStyle={styles.content}
enableOnAndroid={true}
@ -532,6 +801,7 @@ export const StepEditorScreen: React.FC = () => {
))}
</View>
{stepType === 'forming' && renderFormingFields()}
{(stepType === 'bisque_firing' || stepType === 'glaze_firing') && renderFiringFields()}
{stepType === 'glazing' && renderGlazingFields()}
@ -557,24 +827,31 @@ export const StepEditorScreen: React.FC = () => {
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Add notes about this step"
onChangeText={(text) => {
if (text.length <= 5000) {
setNotes(text);
}
}}
placeholder="Add notes about this step (max 5000 characters)"
multiline
numberOfLines={4}
numberOfLines={8}
/>
<Text style={styles.charCount}>
{notes.length}/5000 characters
</Text>
<Button
title={isEditing ? "Update Step" : "Save Step"}
onPress={handleSave}
loading={saving}
style={styles.saveButton}
/>
{isEditing && (
<Button
title="Delete Step"
onPress={handleDelete}
variant="outline"
style={styles.deleteButton}
style={styles.deleteButtonInSticky}
/>
)}
</Card>
@ -606,7 +883,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
},
typeButton: {
minWidth: '45%',
minWidth: '30%',
},
note: {
fontSize: typography.fontSize.sm,
@ -668,9 +945,15 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontWeight: typography.fontWeight.bold,
},
deleteButton: {
saveButton: {
marginTop: spacing.md,
},
deleteButtonInSticky: {
marginTop: spacing.md,
},
catalogButtonInSticky: {
marginBottom: spacing.md,
},
applicationGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
@ -706,4 +989,40 @@ const styles = StyleSheet.create({
glazePickerButton: {
marginBottom: spacing.sm,
},
addGlazeContainer: {
marginBottom: spacing.md,
},
customGlazeRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'flex-end',
},
customGlazeInput: {
flex: 1,
height: 40,
borderWidth: 2,
borderColor: colors.border,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.sm,
backgroundColor: colors.background,
fontSize: typography.fontSize.md,
color: colors.text,
},
catalogButton: {
marginBottom: spacing.md,
},
charCount: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
textAlign: 'right',
marginTop: spacing.xs,
marginBottom: spacing.md,
},
stickyButtonContainer: {
padding: spacing.md,
paddingBottom: spacing.xl,
backgroundColor: colors.backgroundSecondary,
borderTopWidth: 2,
borderTopColor: colors.border,
},
});

View File

@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, StyleSheet, TextInput, Alert, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RouteProp, useRoute } from '@react-navigation/native';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { colors, spacing, typography, borderRadius } from '../lib/theme';
import { UserListItem, UserListType } from '../types';
import { createListItem, getListItems, toggleListItemCompleted, deleteListItem } from '../lib/db/repositories/userListRepository';
import { useAuth } from '../contexts/AuthContext';
import { RootStackParamList } from '../navigation/types';
export const UserListScreen: React.FC = () => {
const route = useRoute<RouteProp<RootStackParamList, 'UserList'>>();
const { type } = route.params;
const { user } = useAuth();
const [items, setItems] = useState<UserListItem[]>([]);
const [newItemText, setNewItemText] = useState('');
const [loading, setLoading] = useState(true);
const title = type === 'shopping' ? 'Shopping List' : 'Wish List';
useEffect(() => {
loadItems();
}, []);
const loadItems = async () => {
if (!user) return;
try {
const data = await getListItems(user.id, type);
setItems(data);
} catch (error) {
console.error('Failed to load list items:', error);
Alert.alert('Error', 'Failed to load list');
} finally {
setLoading(false);
}
};
const handleAddItem = async () => {
if (!user || !newItemText.trim()) return;
try {
await createListItem(user.id, type, newItemText.trim());
setNewItemText('');
loadItems();
} catch (error) {
console.error('Failed to add item:', error);
Alert.alert('Error', 'Failed to add item');
}
};
const handleToggleCompleted = async (itemId: string) => {
try {
await toggleListItemCompleted(itemId);
loadItems();
} catch (error) {
console.error('Failed to toggle item:', error);
Alert.alert('Error', 'Failed to update item');
}
};
const handleDelete = async (itemId: string) => {
Alert.alert(
'Delete Item',
'Are you sure you want to delete this item?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deleteListItem(itemId);
loadItems();
} catch (error) {
console.error('Failed to delete item:', error);
Alert.alert('Error', 'Failed to delete item');
}
},
},
]
);
};
const renderItem = ({ item }: { item: UserListItem }) => (
<Card style={styles.itemCard}>
<View style={styles.itemRow}>
<TouchableOpacity
onPress={() => handleToggleCompleted(item.id)}
style={styles.checkbox}
>
<View style={[styles.checkboxInner, item.isCompleted && styles.checkboxChecked]}>
{item.isCompleted && <Text style={styles.checkmark}></Text>}
</View>
</TouchableOpacity>
<View style={styles.itemContent}>
<Text style={[styles.itemText, item.isCompleted && styles.itemTextCompleted]}>
{item.item}
</Text>
{item.notes && (
<Text style={styles.itemNotes}>{item.notes}</Text>
)}
</View>
<Button
title="Delete"
onPress={() => handleDelete(item.id)}
variant="outline"
size="sm"
/>
</View>
</Card>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
<Card style={styles.addCard}>
<View style={styles.addRow}>
<TextInput
style={styles.input}
value={newItemText}
onChangeText={setNewItemText}
placeholder={`Add ${type === 'shopping' ? 'shopping' : 'wish'} item...`}
onSubmitEditing={handleAddItem}
returnKeyType="done"
/>
<Button
title="Add"
onPress={handleAddItem}
disabled={!newItemText.trim()}
/>
</View>
</Card>
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>
No items yet. Add your first {type === 'shopping' ? 'shopping' : 'wish'} item above!
</Text>
</Card>
}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: {
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.text,
},
addCard: {
margin: spacing.md,
padding: spacing.md,
},
addRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
},
input: {
flex: 1,
height: 48,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background,
fontSize: typography.fontSize.md,
color: colors.text,
},
listContent: {
padding: spacing.md,
paddingTop: 0,
},
itemCard: {
marginBottom: spacing.md,
padding: spacing.md,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
checkbox: {
padding: spacing.xs,
},
checkboxInner: {
width: 24,
height: 24,
borderWidth: 2,
borderColor: colors.border,
borderRadius: borderRadius.sm,
justifyContent: 'center',
alignItems: 'center',
},
checkboxChecked: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
checkmark: {
color: colors.background,
fontSize: 16,
fontWeight: typography.fontWeight.bold,
},
itemContent: {
flex: 1,
},
itemText: {
fontSize: typography.fontSize.md,
color: colors.text,
fontWeight: typography.fontWeight.medium,
},
itemTextCompleted: {
textDecorationLine: 'line-through',
color: colors.textSecondary,
},
itemNotes: {
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
},
emptyCard: {
padding: spacing.lg,
alignItems: 'center',
},
emptyText: {
fontSize: typography.fontSize.md,
color: colors.textSecondary,
textAlign: 'center',
},
});

View File

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

View File

@ -21,19 +21,71 @@ export interface Temperature {
unit: TemperatureUnit;
}
// Weight types
export type WeightUnit = 'lb' | 'oz' | 'kg' | 'g';
export interface Weight {
value: number;
unit: WeightUnit;
}
// Production method for forming
export type ProductionMethod = 'wheel' | 'handbuilding' | 'casting' | 'other';
// Dimensions
export type DimensionUnit = 'in' | 'cm';
export interface Dimensions {
height?: number;
width?: number;
diameter?: number;
unit: DimensionUnit;
notes?: string;
}
// Forming fields (Production step)
export interface FormingFields {
clayBody?: string;
clayWeight?: Weight;
productionMethod?: ProductionMethod;
dimensions?: Dimensions;
}
// Kiln types and positions
export type KilnType = 'electric' | 'gas' | 'raku' | 'other';
export type KilnPosition = 'top' | 'middle' | 'bottom' | 'other';
export interface FiringFields {
cone?: string; // e.g., '04', '6', '10', supports '06'
temperature?: Temperature;
durationMinutes?: number;
kilnNotes?: string;
kilnType?: KilnType;
kilnCustomName?: string; // For "other" kiln type
kilnPosition?: KilnPosition;
kilnPositionNotes?: string;
firingSchedule?: string;
}
export type ApplicationMethod = 'brush' | 'dip' | 'spray' | 'pour' | 'other';
// Glaze layer for multi-glaze application
export type GlazePosition = 'top' | 'middle' | 'bottom' | 'entire' | 'inside' | 'outside' | 'other';
export interface GlazeLayer {
glazeId?: string; // Optional - can be custom
customGlazeName?: string; // For user-entered glazes
application: ApplicationMethod;
position: GlazePosition;
coats?: number;
notes?: string;
}
export interface GlazingFields {
glazeIds: UUID[]; // references to Glaze (or custom)
coats?: number; // layers
application?: ApplicationMethod;
glazeIds?: UUID[]; // DEPRECATED: references to Glaze (or custom) - keep for backwards compat
glazeLayers: GlazeLayer[]; // NEW: Array of layers with full metadata
coats?: number; // DEPRECATED: layers - keep for backwards compat
application?: ApplicationMethod; // DEPRECATED: keep for backwards compat
mixNotes?: string; // notes about glaze mixing ratios (e.g., "50/50", "3:1")
}
@ -48,10 +100,11 @@ export interface StepBase {
}
export type Step =
| (StepBase & { type: 'forming'; forming: FormingFields })
| (StepBase & { type: 'bisque_firing'; firing: FiringFields })
| (StepBase & { type: 'glaze_firing'; firing: FiringFields })
| (StepBase & { type: 'glazing'; glazing: GlazingFields })
| (StepBase & { type: 'forming' | 'trimming' | 'drying' | 'misc' });
| (StepBase & { type: 'trimming' | 'drying' | 'misc' });
export type GlazeFinish = 'glossy' | 'satin' | 'matte' | 'special' | 'unknown';
@ -83,9 +136,23 @@ export type UnitSystem = 'imperial' | 'metric';
export interface Settings {
unitSystem: UnitSystem;
tempUnit: TemperatureUnit;
weightUnit: WeightUnit; // NEW
analyticsOptIn: boolean;
}
// User lists
export type UserListType = 'shopping' | 'wish';
export interface UserListItem {
id: string;
userId: string;
type: UserListType;
item: string;
notes?: string;
isCompleted: boolean;
createdAt: string;
}
// Helper type guards
export function isBisqueFiring(step: Step): step is StepBase & { type: 'bisque_firing'; firing: FiringFields } {
return step.type === 'bisque_firing';
@ -102,3 +169,7 @@ export function isGlazing(step: Step): step is StepBase & { type: 'glazing'; gla
export function isFiringStep(step: Step): step is StepBase & { type: 'bisque_firing' | 'glaze_firing'; firing: FiringFields } {
return step.type === 'bisque_firing' || step.type === 'glaze_firing';
}
export function isForming(step: Step): step is StepBase & { type: 'forming'; forming: FormingFields } {
return step.type === 'forming';
}