Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9929d7321d | ||
|
|
6c74436092 | ||
|
|
d057ba208d | ||
|
|
86f8987dff | ||
|
|
068a51b46b | ||
|
|
5fa30f365f | ||
|
|
b9c7fb325a | ||
|
|
1e7174fa60 | ||
|
|
9eadb0d7fb | ||
|
|
5f6987b84b | ||
|
|
fa42ffb767 | ||
|
|
d2fa050c05 | ||
|
|
f493fd20f2 | ||
|
|
31ae51cf9e | ||
|
|
fa3b9bf6a0 | ||
|
|
ae7350dae6 | ||
|
|
530d0dc34d | ||
|
|
6497ee4dec | ||
|
|
107b593002 | ||
|
|
b7eef743bd | ||
|
|
9498a09b5b | ||
|
|
0aa1b0b431 | ||
|
|
57e1aa658a | ||
|
|
477459edd4 | ||
|
|
21268ddd39 | ||
|
|
7bc8559b44 | ||
|
|
8ad4210908 | ||
|
|
8a3f498874 | ||
|
|
6762a9bff3 | ||
|
|
e17c4c6854 | ||
|
|
355b14faef | ||
|
|
06bc91ac13 | ||
|
|
0b2d65a4e9 | ||
|
|
3daf123399 | ||
|
|
32ffe782ea | ||
|
|
e6ea3d7c5d | ||
|
|
d3b73464e4 | ||
|
|
6d2711e36e | ||
|
|
14c880123c | ||
|
|
8a7a8df477 |
@@ -2,11 +2,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "beenvoice-dev",
|
"name": "beenvoice-web-dev",
|
||||||
"runtimeExecutable": "bun",
|
"runtimeExecutable": "bash",
|
||||||
"runtimeArgs": ["dev"],
|
"runtimeArgs": ["-c", "cd apps/web && PORT=3010 bun run dev"],
|
||||||
"port": 3000,
|
"port": 3010
|
||||||
"autoPort": false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
.next
|
.next
|
||||||
|
**/.next
|
||||||
|
.turbo
|
||||||
|
**/.turbo
|
||||||
|
.expo
|
||||||
|
**/.expo
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
@@ -16,4 +22,11 @@ AGENTS.md
|
|||||||
coverage
|
coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
dist
|
dist
|
||||||
|
**/dist
|
||||||
build
|
build
|
||||||
|
apps/mobile/*
|
||||||
|
!apps/mobile/package.json
|
||||||
|
apps/web/store-assets
|
||||||
|
apps/web/tsconfig.tsbuildinfo
|
||||||
|
.claude
|
||||||
|
beenvoice.icon
|
||||||
|
|||||||
@@ -1,46 +1,19 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
node_modules/
|
||||||
|
.turbo/
|
||||||
|
.next/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# database
|
|
||||||
/prisma/db.sqlite
|
|
||||||
/prisma/db.sqlite-journal
|
|
||||||
db.sqlite
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
.data/
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
|
||||||
.env
|
.env
|
||||||
.env.prod
|
.env.*
|
||||||
.env*.local
|
!.env.example
|
||||||
.env*.production
|
!.env.*.example
|
||||||
|
.ios-release.env
|
||||||
|
|
||||||
# typescript
|
ios/
|
||||||
|
android/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# idea files
|
|
||||||
.idea
|
|
||||||
|
|||||||
@@ -1,461 +1,9 @@
|
|||||||
# beenvoice - AI Assistant Rules
|
# Beenvoice repository guidance
|
||||||
|
|
||||||
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). This file may lag behind; prefer ARCHITECTURE.md for facts.
|
- Use Bun exclusively for dependency installation and workspace scripts.
|
||||||
|
- Run cross-workspace commands from the repository root; use `--filter` for app-specific tasks.
|
||||||
## Project Overview
|
- Put platform-neutral rules, serialized types, constants, and parsing in `packages/domain`.
|
||||||
beenvoice-web is the web app and API for beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, better-auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
|
- Keep browser-only UI in `apps/web` and React Native/native code in `apps/mobile`.
|
||||||
|
- Do not import server implementations into mobile runtime bundles. The `AppRouter` import is type-only.
|
||||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
- Keep Drizzle migrations and their journal together under `apps/web/drizzle`.
|
||||||
|
- Never commit `.env`, signing credentials, native build output, receipts, or customer data.
|
||||||
## Core Development Principles
|
|
||||||
|
|
||||||
### 1. Business-First Approach
|
|
||||||
- **Priority**: Reliability and security over flashy features
|
|
||||||
- **User Experience**: Professional, clean, and intuitive interface
|
|
||||||
- **Data Integrity**: Always validate and sanitize user input
|
|
||||||
- **Error Handling**: Graceful degradation with clear user feedback
|
|
||||||
|
|
||||||
### 2. Type Safety & Code Quality
|
|
||||||
- **TypeScript**: Use strict TypeScript for all new code
|
|
||||||
- **tRPC**: All API calls must go through tRPC for type safety
|
|
||||||
- **Validation**: Use Zod schemas for all input validation
|
|
||||||
- **Error Boundaries**: Implement proper error handling at all levels
|
|
||||||
|
|
||||||
## Tech Stack Guidelines
|
|
||||||
|
|
||||||
### Frontend (Next.js 16 + App Router)
|
|
||||||
- Use App Router patterns consistently
|
|
||||||
- Implement proper loading states and error boundaries
|
|
||||||
- Use React Server Components where appropriate
|
|
||||||
|
|
||||||
### Backend (tRPC + Drizzle)
|
|
||||||
- All business logic goes through tRPC routers
|
|
||||||
- Use Drizzle ORM for all database operations
|
|
||||||
- Implement proper transactions for multi-table operations
|
|
||||||
- Follow existing router patterns in `src/server/api/routers/`
|
|
||||||
|
|
||||||
### Database (PostgreSQL)
|
|
||||||
- Use Drizzle migrations in `drizzle/`; journal must stay in sync (`drizzle/meta/_journal.json`)
|
|
||||||
- `db:push` for local iteration; Docker runs `migrate.ts` on startup
|
|
||||||
- Follow existing schema patterns in `src/server/db/schema.ts`
|
|
||||||
|
|
||||||
### Authentication (better-auth)
|
|
||||||
- Email/password via better-auth + custom `/api/auth/register` REST
|
|
||||||
- Optional Authentik OIDC (`AUTHENTIK_*`); Expo plugin for mobile
|
|
||||||
- Route protection via `src/proxy.ts` (session cookie check) and `protectedProcedure`
|
|
||||||
- `DISABLE_SIGNUPS=true` blocks registration; env booleans parsed in `src/env.js` (not `z.coerce.boolean`)
|
|
||||||
|
|
||||||
### Development Tools
|
|
||||||
- Use ESLint and Prettier for code formatting
|
|
||||||
- Use TypeScript for type safety
|
|
||||||
- Exclusively use bun for development and production. Do not use Node.js or Deno.
|
|
||||||
- Stay away from starting development servers or running builds unless absolutely necessary.
|
|
||||||
- Run lints and typechecks when helpful.
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
|
|
||||||
### UI Components (shadcn/ui)
|
|
||||||
- Use shadcn/ui components as the foundation
|
|
||||||
- Follow existing component patterns
|
|
||||||
- Use `cn()` utility for conditional className merging
|
|
||||||
- Maintain consistent spacing (4px grid system)
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
- **Base UI Components**: `src/components/ui/` - Pure, portable shadcn/ui components
|
|
||||||
- **Project Components**: `src/components/` - Project-specific reusable components
|
|
||||||
- **Page Components**: `src/app/_components/` - Page-specific components
|
|
||||||
|
|
||||||
### UI Component Rules
|
|
||||||
|
|
||||||
#### What Belongs in `src/components/ui/` (Portable)
|
|
||||||
- **Pure shadcn/ui components**: button, input, select, dialog, etc.
|
|
||||||
- **Generic layout components**: page-layout, card, table
|
|
||||||
- **Basic form components**: input, textarea, checkbox, switch
|
|
||||||
- **Navigation components**: breadcrumb, navigation-menu
|
|
||||||
- **Feedback components**: badge, alert-dialog, toast
|
|
||||||
- **Data display**: table, skeleton, progress
|
|
||||||
- **Overlay components**: dialog, sheet, popover, dropdown-menu
|
|
||||||
|
|
||||||
#### What Should Move to `src/components/` (Project-Specific)
|
|
||||||
- **Business logic components**: address-form, status-badge, data-table
|
|
||||||
- **Domain-specific forms**: client-form, invoice-form, business-form
|
|
||||||
- **Custom layouts**: page-header, dashboard-breadcrumbs
|
|
||||||
- **Feature components**: invoice-list, client-list, editable-invoice-items
|
|
||||||
- **Navigation**: Sidebar, Navbar, navigation
|
|
||||||
- **Branding**: logo, AddressAutocomplete
|
|
||||||
|
|
||||||
#### Immediate Reorganization Needed
|
|
||||||
**Move from `src/components/ui/` to `src/components/forms/`:**
|
|
||||||
- `address-form.tsx` - Business-specific address handling
|
|
||||||
- `file-upload.tsx` - Project-specific file upload logic
|
|
||||||
|
|
||||||
**Move from `src/components/ui/` to `src/components/data/`:**
|
|
||||||
- `data-table.tsx` - Enhanced with business logic
|
|
||||||
- `status-badge.tsx` - Invoice status specific
|
|
||||||
- `stats-card.tsx` - Dashboard-specific statistics
|
|
||||||
|
|
||||||
**Move from `src/components/ui/` to `src/components/layout/`:**
|
|
||||||
- `page-layout.tsx` - Project-specific layout patterns
|
|
||||||
- `quick-action-card.tsx` - Dashboard-specific actions
|
|
||||||
- `floating-action-bar.tsx` - Project-specific floating actions
|
|
||||||
|
|
||||||
**Keep in `src/components/ui/` (Portable):**
|
|
||||||
- `button.tsx`, `input.tsx`, `select.tsx` - Pure shadcn/ui
|
|
||||||
- `dialog.tsx`, `sheet.tsx`, `popover.tsx` - Generic overlays
|
|
||||||
- `table.tsx`, `card.tsx`, `badge.tsx` - Base components
|
|
||||||
- `calendar.tsx`, `date-picker.tsx` - Generic date components
|
|
||||||
- `dropdown-menu.tsx`, `navigation-menu.tsx` - Generic navigation
|
|
||||||
- `skeleton.tsx`, `progress.tsx` - Generic loading states
|
|
||||||
|
|
||||||
#### Component Design Principles
|
|
||||||
- **High Reusability**: Components should accept props for customization
|
|
||||||
- **Composition over Inheritance**: Use children props and render props
|
|
||||||
- **Default Values**: Provide sensible defaults for all optional props
|
|
||||||
- **Type Safety**: Use TypeScript interfaces for all props
|
|
||||||
- **Accessibility**: Include proper ARIA labels and keyboard navigation
|
|
||||||
- **Responsive Design**: Mobile-first approach with responsive variants
|
|
||||||
|
|
||||||
#### Component Props Pattern
|
|
||||||
```typescript
|
|
||||||
interface ComponentProps {
|
|
||||||
// Required props
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
// Optional props with defaults
|
|
||||||
variant?: "default" | "success" | "warning" | "error";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
|
|
||||||
// Styling props
|
|
||||||
className?: string;
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
onClick?: () => void;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
|
|
||||||
// Content
|
|
||||||
children?: React.ReactNode;
|
|
||||||
|
|
||||||
// Accessibility
|
|
||||||
"aria-label"?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Component Reusability Guidelines
|
|
||||||
- **Configurable Content**: Use props for text, labels, and content
|
|
||||||
- **Flexible Styling**: Accept className and style props for customization
|
|
||||||
- **Variant System**: Use variant props for different visual states
|
|
||||||
- **Size Variants**: Provide consistent size options (sm, md, lg, xl)
|
|
||||||
- **Icon Support**: Accept icon props for visual customization
|
|
||||||
- **Loading States**: Include loading/skeleton variants
|
|
||||||
- **Error States**: Handle error states gracefully
|
|
||||||
- **Empty States**: Provide empty state components
|
|
||||||
|
|
||||||
#### Component Customization Examples
|
|
||||||
```typescript
|
|
||||||
// Good: Highly customizable component
|
|
||||||
interface DataTableProps<TData> {
|
|
||||||
columns: ColumnDef<TData>[];
|
|
||||||
data: TData[];
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
searchKey?: string;
|
|
||||||
searchPlaceholder?: string;
|
|
||||||
showPagination?: boolean;
|
|
||||||
pageSize?: number;
|
|
||||||
className?: string;
|
|
||||||
emptyState?: React.ReactNode;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Good: Flexible form component
|
|
||||||
interface FormFieldProps {
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
type?: "text" | "email" | "password" | "number";
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
error?: string;
|
|
||||||
className?: string;
|
|
||||||
leftIcon?: React.ReactNode;
|
|
||||||
rightIcon?: React.ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Component File Structure
|
|
||||||
```
|
|
||||||
src/components/
|
|
||||||
├── ui/ # Portable base components
|
|
||||||
│ ├── button.tsx # Pure shadcn/ui components
|
|
||||||
│ ├── input.tsx
|
|
||||||
│ └── ...
|
|
||||||
├── forms/ # Project-specific forms
|
|
||||||
│ ├── address-form.tsx
|
|
||||||
│ ├── client-form.tsx
|
|
||||||
│ └── invoice-form.tsx
|
|
||||||
├── layout/ # Layout components
|
|
||||||
│ ├── page-header.tsx
|
|
||||||
│ ├── sidebar.tsx
|
|
||||||
│ └── navbar.tsx
|
|
||||||
├── data/ # Data display components
|
|
||||||
│ ├── data-table.tsx
|
|
||||||
│ ├── status-badge.tsx
|
|
||||||
│ └── invoice-list.tsx
|
|
||||||
├── navigation/ # Navigation components
|
|
||||||
│ ├── breadcrumbs.tsx
|
|
||||||
│ └── navigation.tsx
|
|
||||||
└── branding/ # Brand-specific components
|
|
||||||
├── logo.tsx
|
|
||||||
└── address-autocomplete.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Styling Guidelines
|
|
||||||
- **Primary Color**: Green (#16a34a) for branding
|
|
||||||
- **Font**: Geist font family for professional typography
|
|
||||||
- **Tailwind CSS**: Use utility classes consistently
|
|
||||||
- **Responsive Design**: Mobile-first approach
|
|
||||||
|
|
||||||
## Business Logic Patterns
|
|
||||||
|
|
||||||
### Invoice Management
|
|
||||||
- **Invoice Creation**: Multi-step process with validation
|
|
||||||
- **Line Items**: Flexible pricing with custom rates
|
|
||||||
- **Status Tracking**: draft → sent → paid/overdue
|
|
||||||
- **PDF Generation**: Professional invoice formatting
|
|
||||||
|
|
||||||
### Client Management
|
|
||||||
- **Contact Information**: Complete address and contact details
|
|
||||||
- **Search & Filter**: Efficient client lookup
|
|
||||||
- **Data Validation**: Proper email and phone validation
|
|
||||||
|
|
||||||
### Business Profile
|
|
||||||
- **Default Business**: One default business per user
|
|
||||||
- **Logo Support**: Professional branding
|
|
||||||
- **Tax Information**: Business tax details
|
|
||||||
|
|
||||||
## API Development Rules
|
|
||||||
|
|
||||||
### tRPC Router Patterns
|
|
||||||
```typescript
|
|
||||||
// Follow this pattern for all routers
|
|
||||||
export const exampleRouter = createTRPCRouter({
|
|
||||||
create: protectedProcedure
|
|
||||||
.input(z.object({ /* validation schema */ }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
// Business logic here
|
|
||||||
}),
|
|
||||||
|
|
||||||
list: protectedProcedure
|
|
||||||
.input(z.object({ /* pagination/filtering */ }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
// Query logic here
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Use toast notifications for user feedback
|
|
||||||
- Implement proper form validation
|
|
||||||
- Handle API errors gracefully
|
|
||||||
- Provide clear error messages
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Always validate user input with Zod
|
|
||||||
- Check user permissions for all operations
|
|
||||||
- Sanitize data before database operations
|
|
||||||
- Use proper authentication checks
|
|
||||||
|
|
||||||
## Database Schema Rules
|
|
||||||
|
|
||||||
### Table Structure
|
|
||||||
- **UUID Primary Keys**: Use `crypto.randomUUID()` for all IDs
|
|
||||||
- **Timestamps**: Include `createdAt` and `updatedAt` fields
|
|
||||||
- **User Relations**: All business data linked to users
|
|
||||||
- **Indexes**: Proper indexing for performance
|
|
||||||
|
|
||||||
### Migrations
|
|
||||||
When adding a new migration:
|
|
||||||
1. Create the SQL file in `drizzle/` following the numbering sequence (e.g. `0011_my_change.sql`)
|
|
||||||
2. **Always update `drizzle/meta/_journal.json`** to include the new entry — Drizzle's migrate runner uses this file to determine which migrations to apply. If the entry is missing, the migration will be silently skipped on deploy.
|
|
||||||
|
|
||||||
The journal entry format:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1780704000000,
|
|
||||||
"tag": "0010_my_change",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Use a Unix timestamp in milliseconds for `when`, incrementing `idx` by 1 from the previous entry.
|
|
||||||
|
|
||||||
Migrations run automatically at container startup via `bun migrate.ts` (see Dockerfile `CMD`). Do not run them manually.
|
|
||||||
|
|
||||||
### Relationships
|
|
||||||
- **Users → Clients**: One-to-many
|
|
||||||
- **Users → Businesses**: One-to-many
|
|
||||||
- **Users → Invoices**: One-to-many
|
|
||||||
- **Clients → Invoices**: One-to-many
|
|
||||||
- **Businesses → Invoices**: One-to-many
|
|
||||||
- **Invoices → Invoice Items**: One-to-many
|
|
||||||
|
|
||||||
## File Naming Conventions
|
|
||||||
|
|
||||||
### Components & Pages
|
|
||||||
- **Components**: PascalCase (e.g., `ClientList.tsx`)
|
|
||||||
- **Pages**: kebab-case (e.g., `new-client.tsx`)
|
|
||||||
- **Layouts**: `layout.tsx` (Next.js convention)
|
|
||||||
- **API Routes**: `route.ts` (Next.js convention)
|
|
||||||
|
|
||||||
### Utilities & Helpers
|
|
||||||
- **Utilities**: camelCase (e.g., `formatCurrency.ts`)
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE
|
|
||||||
- **Types**: PascalCase (e.g., `InvoiceStatus`)
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
1. **Database Schema**: Update schema with migrations
|
|
||||||
2. **tRPC Router**: Add procedures with validation
|
|
||||||
3. **UI Components**: Create components using shadcn/ui
|
|
||||||
4. **Pages**: Implement pages with proper routing
|
|
||||||
5. **Testing**: Verify functionality and error handling
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **ESLint**: Follow existing linting rules
|
|
||||||
- **Prettier**: Consistent code formatting
|
|
||||||
- **TypeScript**: Strict type checking
|
|
||||||
- **Performance**: Optimize database queries and React components
|
|
||||||
|
|
||||||
## Business Logic Specifics
|
|
||||||
|
|
||||||
### Invoice Calculations
|
|
||||||
- **Subtotal**: Sum of all line items
|
|
||||||
- **Tax**: Apply tax rate to subtotal
|
|
||||||
- **Total**: Subtotal + tax
|
|
||||||
- **Currency**: Handle decimal precision properly
|
|
||||||
|
|
||||||
### Status Management
|
|
||||||
- **Draft**: Initial state, editable
|
|
||||||
- **Sent**: Invoice sent to client
|
|
||||||
- **Paid**: Payment received
|
|
||||||
- **Overdue**: Past due date
|
|
||||||
|
|
||||||
### Data Validation
|
|
||||||
- **Email**: Proper email format validation
|
|
||||||
- **Phone**: International phone number support
|
|
||||||
- **Address**: Complete address validation
|
|
||||||
- **Currency**: Proper decimal handling
|
|
||||||
|
|
||||||
## Performance Guidelines
|
|
||||||
|
|
||||||
### Database Optimization
|
|
||||||
- Use proper indexes for frequently queried fields
|
|
||||||
- Implement pagination for large datasets
|
|
||||||
- Use transactions for data consistency
|
|
||||||
- Optimize query patterns
|
|
||||||
|
|
||||||
### Frontend Performance
|
|
||||||
- Use React.memo for expensive components
|
|
||||||
- Implement proper loading states
|
|
||||||
- Optimize bundle size
|
|
||||||
- Use Next.js Image component for images
|
|
||||||
|
|
||||||
### Caching Strategy
|
|
||||||
- Use React Query for client-side caching
|
|
||||||
- Implement proper cache invalidation
|
|
||||||
- Use Next.js caching where appropriate
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- All routes require proper authentication
|
|
||||||
- Check user ownership for all operations
|
|
||||||
- Implement proper session management
|
|
||||||
- Use secure password hashing
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
- Validate all user inputs
|
|
||||||
- Sanitize data before database operations
|
|
||||||
- Implement proper error handling
|
|
||||||
- Use HTTPS in production
|
|
||||||
|
|
||||||
### Business Data Security
|
|
||||||
- User data isolation
|
|
||||||
- Proper access controls
|
|
||||||
- Audit trails for sensitive operations
|
|
||||||
- Secure API endpoints
|
|
||||||
|
|
||||||
## Testing & Quality Assurance
|
|
||||||
|
|
||||||
### Manual Testing Checklist
|
|
||||||
- [ ] Authentication flows work correctly
|
|
||||||
- [ ] Form validation provides clear feedback
|
|
||||||
- [ ] Responsive design on all screen sizes
|
|
||||||
- [ ] Database operations handle errors gracefully
|
|
||||||
- [ ] PDF generation works correctly
|
|
||||||
- [ ] Navigation and routing function properly
|
|
||||||
|
|
||||||
### Code Review Guidelines
|
|
||||||
- Check for proper error handling
|
|
||||||
- Verify type safety
|
|
||||||
- Ensure consistent styling
|
|
||||||
- Review security implications
|
|
||||||
- Test business logic accuracy
|
|
||||||
|
|
||||||
## Deployment & Production
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
- Use proper environment variables (see `.env.example` and `src/env.js`)
|
|
||||||
- `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` must match the public hostname
|
|
||||||
- Secure database connections; `DB_DISABLE_SSL=true` for compose Postgres
|
|
||||||
|
|
||||||
### Database Management
|
|
||||||
- Use migrations for schema changes
|
|
||||||
- Backup data regularly
|
|
||||||
- Monitor database performance
|
|
||||||
- Handle database errors gracefully
|
|
||||||
|
|
||||||
## Common Patterns & Anti-Patterns
|
|
||||||
|
|
||||||
### ✅ Do's
|
|
||||||
- Use tRPC for all API calls
|
|
||||||
- Implement proper loading states
|
|
||||||
- Use toast notifications for feedback
|
|
||||||
- Follow existing component patterns
|
|
||||||
- Validate all user inputs
|
|
||||||
- Use proper TypeScript types
|
|
||||||
|
|
||||||
### ❌ Don'ts
|
|
||||||
- Don't use direct fetch calls
|
|
||||||
- Don't skip input validation
|
|
||||||
- Don't ignore error handling
|
|
||||||
- Don't hardcode business logic
|
|
||||||
- Don't use any types unnecessarily
|
|
||||||
- Don't skip proper authentication checks
|
|
||||||
|
|
||||||
## Emergency Procedures
|
|
||||||
|
|
||||||
### Critical Issues
|
|
||||||
- **Data Loss**: Immediate database backup
|
|
||||||
- **Security Breach**: Rotate all secrets
|
|
||||||
- **Performance Issues**: Database query optimization
|
|
||||||
- **User Complaints**: Prioritize user experience fixes
|
|
||||||
|
|
||||||
### Rollback Strategy
|
|
||||||
- Keep database migrations reversible
|
|
||||||
- Maintain version control for all changes
|
|
||||||
- Test rollback procedures regularly
|
|
||||||
- Document emergency procedures
|
|
||||||
|
|
||||||
## Remember
|
|
||||||
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
|
|
||||||
|
|
||||||
- Don't create demo pages unless absolutely necessary.
|
|
||||||
- Don't create unnecessary complexity.
|
|
||||||
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
|
|
||||||
- Don't start new dev servers unless asked.
|
|
||||||
- Don't start drizzle studio- you cannot do anything with it.
|
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM oven/bun:1 AS base
|
|
||||||
WORKDIR /usr/src/app
|
FROM oven/bun:1.3.14 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS install
|
FROM base AS install
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
COPY apps/mobile/package.json apps/mobile/package.json
|
||||||
|
COPY packages/domain/package.json packages/domain/package.json
|
||||||
|
RUN bun install --frozen-lockfile --filter @beenvoice/web
|
||||||
|
|
||||||
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
|
# Next's production build runs under Node because Bun can fail during the
|
||||||
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
|
# page-data worker phase on Linux arm64. Dependencies still come from Bun.
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /app
|
||||||
COPY --from=install /usr/src/app/node_modules node_modules
|
COPY --from=install /app ./
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
ARG BETTER_AUTH_URL=http://localhost:3000
|
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Low-memory Docker build profile:
|
|
||||||
# - skip tsc inside `next build` (run `bun run check` in CI instead)
|
|
||||||
ENV DOCKER_BUILD=1 \
|
ENV DOCKER_BUILD=1 \
|
||||||
|
DISABLE_REACT_COMPILER=1 \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
SKIP_ENV_VALIDATION=1 \
|
SKIP_ENV_VALIDATION=1 \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
@@ -26,6 +28,8 @@ ENV DOCKER_BUILD=1 \
|
|||||||
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
||||||
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
|
||||||
|
WORKDIR /app/apps/web
|
||||||
RUN node ./node_modules/next/dist/bin/next build
|
RUN node ./node_modules/next/dist/bin/next build
|
||||||
|
|
||||||
FROM base AS release
|
FROM base AS release
|
||||||
@@ -34,16 +38,20 @@ ENV NODE_ENV=production \
|
|||||||
HOSTNAME=0.0.0.0 \
|
HOSTNAME=0.0.0.0 \
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=build /usr/src/app/.next ./.next
|
COPY --from=install /app/node_modules ./node_modules
|
||||||
COPY --from=build /usr/src/app/public ./public
|
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
COPY --from=install /usr/src/app/node_modules node_modules
|
COPY --from=build /app/package.json ./package.json
|
||||||
COPY --from=build /usr/src/app/package.json ./package.json
|
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||||
COPY --from=build /usr/src/app/drizzle.config.ts ./drizzle.config.ts
|
COPY --from=build /app/packages/domain ./packages/domain
|
||||||
COPY --from=build /usr/src/app/drizzle ./drizzle
|
COPY --from=build /app/apps/web/.next ./apps/web/.next
|
||||||
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./migrate.ts
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
|
||||||
|
COPY --from=build /app/apps/web/drizzle ./apps/web/drizzle
|
||||||
|
COPY --from=build /app/apps/web/src/server/db/migrate.ts ./apps/web/src/server/db/migrate.ts
|
||||||
|
|
||||||
RUN chmod -R a+rX drizzle public migrate.ts
|
RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrate.ts
|
||||||
|
|
||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["sh", "-c", "bun migrate.ts && bun run start"]
|
WORKDIR /app/apps/web
|
||||||
|
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,278 +1,81 @@
|
|||||||

|
# beenvoice
|
||||||
|
|
||||||
# beenvoice-web
|
Beenvoice is a freelancer and small-business invoicing platform with a Next.js web application/API and an Expo mobile companion. The repository is a Bun workspace orchestrated by Turborepo.
|
||||||
|
|
||||||
Web application and API for **beenvoice** — invoicing for freelancers and small businesses. Includes the Next.js dashboard, tRPC API, better-auth, PostgreSQL persistence, PDF/email delivery, time tracking, and an MCP automation endpoint.
|
## Workspace map
|
||||||
|
|
||||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
```text
|
||||||
**Architecture:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
beenvoice/
|
||||||
**Mobile companion:** [beenvoice-app](https://git.soconnor.dev/soconnor/beenvoice-app) (separate repo; often checked out beside this one in a workspace)
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle
|
||||||
|
│ └── mobile/ # Expo Router mobile app and iOS widgets
|
||||||
|
├── packages/
|
||||||
|
│ └── domain/ # Platform-neutral shared rules and parsing
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose*.yml
|
||||||
|
├── package.json
|
||||||
|
└── turbo.json
|
||||||
|
```
|
||||||
|
|
||||||
## Stack
|
The mobile app consumes the same tRPC API and Better Auth sessions as the web app. It imports the server router type at compile time, while runtime-safe shared behavior lives in `@beenvoice/domain`.
|
||||||
|
|
||||||
| Layer | Technology |
|
## Quick start
|
||||||
|-------|------------|
|
|
||||||
| App | Next.js 16 App Router, React 19 |
|
|
||||||
| API | tRPC 11 + SuperJSON |
|
|
||||||
| Database | PostgreSQL 17, Drizzle ORM |
|
|
||||||
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
|
|
||||||
| UI | shadcn/ui, Tailwind CSS v4 |
|
|
||||||
| Email / PDF | Resend, `@react-pdf/renderer` |
|
|
||||||
| Runtime | Bun |
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Clients, businesses, invoices (line items, tax, status workflow)
|
|
||||||
- Time clock with one running timer per user; clock-out can append invoice lines
|
|
||||||
- Expenses, payments, recurring invoices, invoice templates
|
|
||||||
- PDF export and email delivery (Resend)
|
|
||||||
- Public invoice links (`/i/[token]`)
|
|
||||||
- CSV import, reports, platform branding / admin settings
|
|
||||||
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
|
|
||||||
- Optional Authentik OIDC SSO
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- [Bun](https://bun.sh) 1.x
|
|
||||||
- Docker & Docker Compose (for PostgreSQL locally or full-stack deploy)
|
|
||||||
- Git
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
### 1. Clone and install
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
|
|
||||||
cd beenvoice-web
|
|
||||||
bun install
|
bun install
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Environment
|
cp apps/web/.env.example apps/web/.env.local
|
||||||
|
cp apps/mobile/.env.example apps/mobile/.env
|
||||||
|
|
||||||
```bash
|
bun run --filter @beenvoice/web docker:up
|
||||||
cp .env.example .env.local
|
bun run --filter @beenvoice/web db:push
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env.local` for local dev. Minimum:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
|
||||||
DB_DISABLE_SSL=true
|
|
||||||
AUTH_SECRET=your-dev-secret # openssl rand -base64 32
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*` blank unless you need them.
|
|
||||||
|
|
||||||
### 3. Database
|
|
||||||
|
|
||||||
Start Postgres (dev compose exposes port 5432):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
|
|
||||||
|
|
||||||
Apply schema (pick one):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:push # fast iteration during development
|
|
||||||
# bun run db:migrate # same migrations the Docker image runs in production
|
|
||||||
```
|
|
||||||
|
|
||||||
**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated but locked `demo@example.com` account (`db:push` does not). Provision a private, temporary password when review access is needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
|
|
||||||
```
|
|
||||||
|
|
||||||
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish the password. Re-run the command to rotate it; migration 0027 disables the previously public credential. The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
|
||||||
|
|
||||||
### 4. Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
|
`bun run dev` starts Next.js on port 3000 and Expo Metro on port 8082. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
|
||||||
|
|
||||||
## Docker deployment (app + database)
|
Useful workspace commands:
|
||||||
|
|
||||||
The production compose file runs the Next.js app and PostgreSQL.
|
|
||||||
|
|
||||||
**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
|
|
||||||
|
|
||||||
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. `docker-compose.yml` does not set container memory or CPU limits — containers can use whatever the Docker host provides.
|
|
||||||
|
|
||||||
### 1. Configure
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
bun run typecheck
|
||||||
|
bun run lint
|
||||||
|
bun run test
|
||||||
|
bun run build
|
||||||
|
bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
Set at least:
|
Run an app-specific command with a workspace filter:
|
||||||
|
|
||||||
```env
|
|
||||||
AUTH_SECRET=<openssl rand -base64 32>
|
|
||||||
BETTER_AUTH_URL=https://your-public-hostname
|
|
||||||
NEXT_PUBLIC_APP_URL=https://your-public-hostname
|
|
||||||
```
|
|
||||||
|
|
||||||
`BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must match the URL users actually use in the browser (scheme + host + port). If they point at `localhost` but you access the app via another hostname, sign-up and sign-in will fail (often with a vague **"REQUIRED"** toast).
|
|
||||||
|
|
||||||
`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing `NEXT_PUBLIC_APP_URL`, white-label defaults, or `NEXT_PUBLIC_AUTHENTIK_ENABLED`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build --no-cache app
|
bun run --filter @beenvoice/web db:migrate
|
||||||
|
bun run --filter @beenvoice/mobile ios
|
||||||
|
bun run --filter @beenvoice/mobile ios:release:upload
|
||||||
```
|
```
|
||||||
|
|
||||||
`BETTER_AUTH_URL` and `AUTH_SECRET` are read at **container runtime** from `.env` — you can change them without rebuilding, then restart the app container.
|
## Production Docker deployment
|
||||||
|
|
||||||
### 2. First start (or after code changes)
|
Keep production web configuration in `apps/web/.env`, then run from the repository root:
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/docker-deploy.sh
|
|
||||||
# or: bun run docker:deploy
|
|
||||||
# or: docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
`--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
|
|
||||||
|
|
||||||
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
|
|
||||||
|
|
||||||
### Scheduled recurring invoices
|
|
||||||
|
|
||||||
The app container does not run a cron daemon. It starts the web server with
|
|
||||||
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
|
|
||||||
when something calls `POST /api/cron/generate-recurring` with
|
|
||||||
`Authorization: Bearer $CRON_SECRET`.
|
|
||||||
|
|
||||||
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
|
|
||||||
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
|
|
||||||
external scheduler to call
|
|
||||||
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
|
|
||||||
|
|
||||||
### 3. Updating an existing deploy
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
|
./scripts/docker-deploy.sh
|
||||||
# or: docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Command | New code? | Migrations run? |
|
The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.js app under Node, and runs migrations plus the web server under Bun.
|
||||||
|---------|-----------|-----------------|
|
|
||||||
| `git pull` only | No | No |
|
|
||||||
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
|
||||||
| `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
|
|
||||||
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
|
|
||||||
|
|
||||||
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
|
|
||||||
|
|
||||||
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
|
|
||||||
|
|
||||||
### Coolify
|
|
||||||
|
|
||||||
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
|
|
||||||
|
|
||||||
### 4. Sign-ups
|
|
||||||
|
|
||||||
Registration is **enabled** by default. To block new email/password accounts:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DISABLE_SIGNUPS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the literal strings `true` or `false` (or omit the variable). Do not rely on bare boolean coercion from shell/compose — the app parses these explicitly.
|
|
||||||
|
|
||||||
### 5. Optional services
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email |
|
|
||||||
| `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) |
|
|
||||||
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
|
|
||||||
| `DISABLE_SIGNUPS=true` | Block new registrations |
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
|
||||||
beenvoice-web/
|
|
||||||
├── src/app/ # Routes (dashboard, auth, /api/*)
|
|
||||||
├── src/server/api/ # tRPC routers
|
|
||||||
├── src/server/db/ # Drizzle schema, pool, migrate.ts
|
|
||||||
├── src/components/ # UI (ui/, forms/, layout/, branding/)
|
|
||||||
├── src/lib/ # auth, PDF, email, branding helpers
|
|
||||||
├── drizzle/ # SQL migrations
|
|
||||||
├── Dockerfile # Production image (migrate + next start)
|
|
||||||
├── docker-compose.yml # App + Postgres + Garage (deploy)
|
|
||||||
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
|
|
||||||
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
|
|
||||||
├── docker-compose.dev.yml # Postgres only (local dev)
|
|
||||||
└── docs/ # Architecture and UI guides
|
|
||||||
```
|
|
||||||
|
|
||||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# App
|
|
||||||
bun run dev # next dev --turbo
|
|
||||||
bun run build # production build
|
|
||||||
bun run start # next start
|
|
||||||
bun run check # eslint + tsc
|
|
||||||
|
|
||||||
# Database
|
|
||||||
bun run db:push # push schema (local dev)
|
|
||||||
bun run db:migrate # run drizzle migrations
|
|
||||||
bun run db:generate # generate new migration SQL
|
|
||||||
bun run db:studio # Drizzle Studio
|
|
||||||
|
|
||||||
# Formatting
|
|
||||||
bun run lint
|
|
||||||
bun run lint:fix
|
|
||||||
bun run format:write
|
|
||||||
bun run typecheck
|
|
||||||
|
|
||||||
# Docker helpers
|
|
||||||
bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
|
|
||||||
bun run docker:down # stop dev Postgres + colima
|
|
||||||
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
|
||||||
|
|
||||||
## API surface
|
|
||||||
|
|
||||||
| Endpoint | Auth | Purpose |
|
|
||||||
|----------|------|---------|
|
|
||||||
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
|
|
||||||
| `/api/auth/*` | Varies | better-auth + custom register/reset REST |
|
|
||||||
| `/api/mcp` | API key only | JSON-RPC automation tools |
|
|
||||||
| `/i/[token]` | Public token | Client invoice view |
|
|
||||||
| `/api/i/[token]/pdf` | Public token | Invoice PDF download |
|
|
||||||
|
|
||||||
Business logic lives in `src/server/api/routers/` with Zod validation.
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
- **Runtime branding:** Dashboard → Administration (platform settings)
|
|
||||||
- **Build-time defaults:** `NEXT_PUBLIC_BRAND_*` in `.env` (rebuild Docker image to apply)
|
|
||||||
- **Theme / fonts:** `src/styles/globals.css`, appearance settings in the app
|
|
||||||
- **Logo component:** `src/components/branding/logo.tsx`
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Doc | Contents |
|
- [Web setup](./apps/web/README.md)
|
||||||
|-----|----------|
|
- [Web architecture](./apps/web/docs/ARCHITECTURE.md)
|
||||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
- [Mobile setup](./apps/mobile/README.md)
|
||||||
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
|
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
|
||||||
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
- [Shared domain package](./packages/domain/README.md)
|
||||||
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
|
||||||
|
|
||||||
## License
|
## Product concepts
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
- Clients with optional default hourly rates
|
||||||
|
- Businesses and sender branding
|
||||||
|
- Draft, sent, paid, and overdue invoices
|
||||||
|
- Time tracking and invoice attachment
|
||||||
|
- Recurring invoices, expenses, payments, and templates
|
||||||
|
- Public invoice links and API-key-authenticated automation
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# beenvoice API base URL (no trailing slash)
|
||||||
|
# Omit or leave unset in production builds — app defaults to https://beenvoice.app
|
||||||
|
# Local dev on physical iPhone: use your Mac's LAN IP, e.g. http://192.168.1.42:3000
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
.ios-release.env
|
||||||
|
dist/ios-release/
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Copy to .ios-release.env and fill in (file is gitignored).
|
||||||
|
# Used by: bun run ios:release
|
||||||
|
|
||||||
|
# Apple Developer team ID (10 chars, Membership details in developer.apple.com)
|
||||||
|
APPLE_TEAM_ID=
|
||||||
|
|
||||||
|
# Before export: create an Apple Distribution cert in Xcode
|
||||||
|
# (Settings → Accounts → your team → Manage Certificates → + → Apple Distribution)
|
||||||
|
#
|
||||||
|
# If export fails with "profile doesn't include signing certificate", regenerate App Store
|
||||||
|
# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget,
|
||||||
|
# Optional overrides when those App Store profile names differ from the bundle IDs:
|
||||||
|
# IOS_MAIN_APPSTORE_PROFILE_NAME=com.beenvoice.app
|
||||||
|
# IOS_WIDGET_APPSTORE_PROFILE_NAME=com.beenvoice.app.ExpoWidgetsTarget
|
||||||
|
# then re-run the full release (not --export-only).
|
||||||
|
|
||||||
|
# Production API baked into the JS bundle (App Store / TestFlight)
|
||||||
|
EXPO_PUBLIC_API_URL=https://beenvoice.app
|
||||||
|
|
||||||
|
# App Store Connect API key (Users and Access → Integrations → App Store Connect API)
|
||||||
|
# Create a key with Developer role. Download the .p8 once — Apple won't show it again.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
# Path to AuthKey_XXXXXX.p8 (keep outside the repo or in a secrets folder)
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
|
||||||
|
# Optional: auto-increment CFBundleVersion before each archive (agvtool)
|
||||||
|
IOS_BUMP_BUILD=1
|
||||||
|
|
||||||
|
# Optional: skip `expo prebuild` when native project is already up to date
|
||||||
|
# IOS_SKIP_PREBUILD=1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Beenvoice mobile — agent notes
|
||||||
|
|
||||||
|
Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- [README.md](./README.md) — setup and run
|
||||||
|
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — routing, auth, accounts, tRPC, widgets
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Package manager**: Bun only
|
||||||
|
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../web/src/*`)
|
||||||
|
- **Shared domain**: import platform-neutral behavior from `@beenvoice/domain`
|
||||||
|
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
|
||||||
|
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
|
||||||
|
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
|
||||||
|
- **Widgets**: all Live Activity UI must be inside the `"widget"` function in `widgets/TimeClockActivity.tsx`
|
||||||
|
- **Metro**: port 8082; dev client required (not Expo Go)
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| Concern | Path |
|
||||||
|
|---------|------|
|
||||||
|
| Root providers | `app/_layout.tsx` |
|
||||||
|
| Multi-account | `contexts/AccountsContext.tsx`, `lib/accounts.ts` |
|
||||||
|
| Session migration | `lib/auth-storage.ts` |
|
||||||
|
| tRPC | `lib/trpc.tsx` |
|
||||||
|
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
|
||||||
|
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
|
||||||
|
|
||||||
|
## Server workspace
|
||||||
|
|
||||||
|
Sibling `../web` — run `bun run dev` at the repository root to start both apps.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# beenvoice Mobile
|
||||||
|
|
||||||
|
Expo companion for the [Beenvoice web app](../web/README.md) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API**, Better Auth sessions, and platform-neutral domain package.
|
||||||
|
|
||||||
|
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) 1.3+
|
||||||
|
- Beenvoice API running ([setup](../web/README.md))
|
||||||
|
- Xcode + iOS Simulator (or device) for native dev build
|
||||||
|
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mobile
|
||||||
|
bun install
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Simulator
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Physical iPhone — Mac LAN IP
|
||||||
|
EXPO_PUBLIC_API_URL=http://192.168.1.42:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `EXPO_PUBLIC_API_URL` in production builds to default to `https://beenvoice.app`.
|
||||||
|
|
||||||
|
Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `beenvoice://` in `trustedOrigins`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1, from the repository root — API + Metro
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Terminal 2, from the repository root — native app
|
||||||
|
bun run --filter @beenvoice/mobile ios
|
||||||
|
```
|
||||||
|
|
||||||
|
Metro uses port **8082** (avoids other Expo projects on 8081).
|
||||||
|
|
||||||
|
Metro only (app already installed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the **beenvoice** dev build on the simulator — not Expo Go.
|
||||||
|
|
||||||
|
### After native changes
|
||||||
|
|
||||||
|
Icon (`assets/beenvoice.icon`), widgets, or new native modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx expo prebuild --platform ios --clean
|
||||||
|
bun run ios
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Area | Details |
|
||||||
|
|------|---------|
|
||||||
|
| **Auth** | Sign in, register, forgot/reset password; official or self-hosted server |
|
||||||
|
| **Multi-account** | Bitwarden-style switcher; per-account session in SecureStore |
|
||||||
|
| **Dashboard** | Revenue, pending, overdue, running timer, recent invoices |
|
||||||
|
| **Timer** | Clock in/out, client + invoice + rate; optional description (default "Clock In"); iOS Live Activity |
|
||||||
|
| **Entities** | Clients and businesses — list, create, edit |
|
||||||
|
| **Invoices** | List, filter, create, edit, status updates |
|
||||||
|
| **Settings** | Profile, accounts, theme, per-account app lock (PIN + Face ID), sign out |
|
||||||
|
| **App lock** | Per-account; locks on background return |
|
||||||
|
|
||||||
|
## Auth & accounts (summary)
|
||||||
|
|
||||||
|
- **Guest** auth storage: `beenvoice:guest` until first successful login
|
||||||
|
- **Per account**: `beenvoice:auth:{host::userId}` in SecureStore
|
||||||
|
- After login, `finalizeAuthenticatedAccount()` migrates session keys before activating the account (avoids double login)
|
||||||
|
- **Server picker**: Official (`beenvoice.app`) or custom URL on auth screens
|
||||||
|
|
||||||
|
Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model)
|
||||||
|
|
||||||
|
## Deep links & Shortcuts
|
||||||
|
|
||||||
|
| URL | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `beenvoice://reset-password?token=…` | Reset password |
|
||||||
|
| `beenvoice://timer` | Open time clock |
|
||||||
|
| `beenvoice://shortcuts/clock-in` | Clock in (last client) |
|
||||||
|
| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title |
|
||||||
|
| `beenvoice://shortcuts/clock-out` | Clock out running timer |
|
||||||
|
|
||||||
|
**iOS Shortcuts / Siri** (requires a **native dev client or TestFlight build** — not Expo Go; iOS **18+**):
|
||||||
|
|
||||||
|
- **Clock In** — starts the timer with your last client
|
||||||
|
- **Clock Out** — stops the running timer
|
||||||
|
- **Open Time Clock** — opens the timer tab
|
||||||
|
|
||||||
|
Shortcuts are **not pre-installed** in your Shortcuts library. To add one:
|
||||||
|
|
||||||
|
1. Install a fresh native build (`bunx expo prebuild --platform ios && bun run ios`, or a new EAS/TestFlight build).
|
||||||
|
2. Open beenvoice once while signed in.
|
||||||
|
3. Open **Shortcuts** → **+** → **Add Action** → search **beenvoice** (or “Clock In”).
|
||||||
|
4. Choose **Clock In**, **Clock Out**, or **Open Time Clock**.
|
||||||
|
5. Pick a client once on the Timer tab before the first clock-in shortcut.
|
||||||
|
|
||||||
|
You can also say “Hey Siri, clock in with beenvoice”. Settings → Shortcuts & Siri in the app has a setup guide and test links.
|
||||||
|
|
||||||
|
If beenvoice actions never appear when searching in Shortcuts, the installed build predates App Intents — rebuild and reinstall.
|
||||||
|
|
||||||
|
**Test deep links:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcrun simctl openurl booted "beenvoice://shortcuts/clock-in"
|
||||||
|
xcrun simctl openurl booted "beenvoice://shortcuts/clock-out"
|
||||||
|
xcrun simctl openurl booted "beenvoice://timer"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx # Providers, auth guard
|
||||||
|
(auth)/ # sign-in, register, password flows
|
||||||
|
(app)/ # tab shell + nested stacks
|
||||||
|
components/ # UI, forms, chrome, time clock
|
||||||
|
contexts/ # Auth, Accounts, AppLock, Theme
|
||||||
|
lib/ # tRPC, auth storage, config, theming
|
||||||
|
widgets/ # iOS Live Activity (TimeClockActivity)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| `PlatformConstants` / runtime not ready | Stop other Metro on 8081/8082; rebuild with `prebuild --clean` |
|
||||||
|
| Expo Go | Use `bun run ios` dev build |
|
||||||
|
| API errors on device | `EXPO_PUBLIC_API_URL` = Mac LAN IP; server `BETTER_AUTH_URL` must match |
|
||||||
|
| Live Activity empty | Rebuild iOS; widget UI must live inside `"widget"` function |
|
||||||
|
| Login twice | Server + app versions with session migration (`lib/auth-storage.ts`) |
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Web README](../web/README.md)
|
||||||
|
- [Web architecture](../web/docs/ARCHITECTURE.md)
|
||||||
|
- [Workspace root README](../../README.md)
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "beenvoice",
|
||||||
|
"slug": "beenvoice",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "beenvoice",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
|
"buildNumber": "28",
|
||||||
|
"icon": "./assets/beenvoice.icon",
|
||||||
|
"infoPlist": {
|
||||||
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
|
||||||
|
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.",
|
||||||
|
"NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#D9D9D9",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.USE_BIOMETRIC",
|
||||||
|
"android.permission.USE_FINGERPRINT",
|
||||||
|
"android.permission.CAMERA"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-dev-client",
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
"ios": {
|
||||||
|
"deploymentTarget": "18.0",
|
||||||
|
"buildReactNativeFromSource": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#D9D9D9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-widgets",
|
||||||
|
{
|
||||||
|
"groupIdentifier": "group.com.beenvoice.app",
|
||||||
|
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"./plugins/withSyncWidgetVersions.js",
|
||||||
|
"./plugins/withLiveActivityBannerFrame.js",
|
||||||
|
"./plugins/withStableWidgetsChildIdentity.js",
|
||||||
|
[
|
||||||
|
"expo-local-authentication",
|
||||||
|
{
|
||||||
|
"faceIDPermission": "Unlock beenvoice with Face ID when returning to the app."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"color": "#18181B",
|
||||||
|
"sounds": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@react-native-community/datetimepicker",
|
||||||
|
"./plugins/withAppIntents.js",
|
||||||
|
"./plugins/withAppStoreSigning.js",
|
||||||
|
"expo-sharing",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "beenvoice imports receipt photos for expense tracking.",
|
||||||
|
"cameraPermission": "beenvoice uses the camera to scan expense receipts."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withExpoMlkitOcrEnv.js",
|
||||||
|
{
|
||||||
|
"iosEngine": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-font",
|
||||||
|
"expo-image",
|
||||||
|
"expo-status-bar",
|
||||||
|
"expo-web-browser"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"build": {
|
||||||
|
"experimental": {
|
||||||
|
"ios": {
|
||||||
|
"appExtensions": [
|
||||||
|
{
|
||||||
|
"targetName": "ExpoWidgetsTarget",
|
||||||
|
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget",
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.security.application-groups": [
|
||||||
|
"group.com.beenvoice.app"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectId": "cdc31bf6-9c8d-49cd-aa28-7f56cbffd7d2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "soconnor0919"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
|
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||||
|
import { OnboardingGate } from "@/components/OnboardingGate";
|
||||||
|
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||||
|
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
|
const tintColor = colors.primary;
|
||||||
|
const labelColor = colors.mutedForeground;
|
||||||
|
const tabContentStyle = { backgroundColor: colors.background };
|
||||||
|
const tabBarBlur =
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? isDark
|
||||||
|
? "systemChromeMaterialDark"
|
||||||
|
: "systemChromeMaterialLight"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLockProvider>
|
||||||
|
<NativeTabs
|
||||||
|
tintColor={tintColor}
|
||||||
|
iconColor={{
|
||||||
|
default: labelColor,
|
||||||
|
selected: tintColor,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: labelColor }}
|
||||||
|
blurEffect={tabBarBlur}
|
||||||
|
disableTransparentOnScrollEdge
|
||||||
|
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
|
||||||
|
>
|
||||||
|
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "house", selected: "house.fill" }}
|
||||||
|
md="home"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "timer", selected: "timer" }}
|
||||||
|
md="timer"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
|
||||||
|
md="corporate_fare"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||||
|
md="description"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="more" role="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
|
||||||
|
md="more_horiz"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
<OnboardingGate />
|
||||||
|
<InvoiceReminderSync />
|
||||||
|
<TimeClockLiveActivitySync />
|
||||||
|
<ShortcutHandler />
|
||||||
|
<AppLockOverlay />
|
||||||
|
</AppLockProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export default function EntitiesLayout() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
headerStyle: { backgroundColor: colors.cardGlass },
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerTintColor: colors.foreground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Entities",
|
||||||
|
headerShown: false,
|
||||||
|
statusBarTranslucent: true,
|
||||||
|
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="clients/new"
|
||||||
|
options={{
|
||||||
|
title: "New client",
|
||||||
|
headerBackTitle: "Entities",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="clients/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Client",
|
||||||
|
headerBackTitle: "Entities",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="clients/edit/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Edit client",
|
||||||
|
headerBackTitle: "Client",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="businesses/new"
|
||||||
|
options={{
|
||||||
|
title: "New business",
|
||||||
|
headerBackTitle: "Entities",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="businesses/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Business",
|
||||||
|
headerBackTitle: "Entities",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="businesses/edit/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Edit business",
|
||||||
|
headerBackTitle: "Business",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function BusinessDetailScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createBusinessDetailStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const businessQuery = api.businesses.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDefault = api.businesses.setDefault.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.businesses.getAll.invalidate();
|
||||||
|
if (id) void utils.businesses.getById.invalidate({ id });
|
||||||
|
Alert.alert("Default updated", "This business is now your default.");
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not set default", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid business" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (businessQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading business…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = businessQuery.data;
|
||||||
|
if (!business) {
|
||||||
|
return <LoadingScreen message="Business not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.name}>{business.name}</Text>
|
||||||
|
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
|
||||||
|
</View>
|
||||||
|
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
|
||||||
|
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||||
|
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
|
||||||
|
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Details">
|
||||||
|
{business.taxId ? (
|
||||||
|
<DetailRow label="Tax ID" value={business.taxId} />
|
||||||
|
) : null}
|
||||||
|
<DetailRow
|
||||||
|
label="Email sending"
|
||||||
|
value={business.resendDomain ? "Configured" : "Not configured"}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(business.addressLine1 || business.city || business.state) && (
|
||||||
|
<Card title="Address">
|
||||||
|
{business.addressLine1 ? (
|
||||||
|
<Text style={styles.body}>{business.addressLine1}</Text>
|
||||||
|
) : null}
|
||||||
|
{business.addressLine2 ? (
|
||||||
|
<Text style={styles.body}>{business.addressLine2}</Text>
|
||||||
|
) : null}
|
||||||
|
{(business.city || business.state || business.postalCode) && (
|
||||||
|
<Text style={styles.body}>
|
||||||
|
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title="Edit business"
|
||||||
|
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
|
||||||
|
/>
|
||||||
|
{!business.isDefault ? (
|
||||||
|
<Button
|
||||||
|
title="Set as default"
|
||||||
|
variant="secondary"
|
||||||
|
loading={setDefault.isPending}
|
||||||
|
onPress={() => setDefault.mutate({ id: business.id })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={detailStyles.row}>
|
||||||
|
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
nameRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.primary,
|
||||||
|
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.foreground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
export default function EditBusinessScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Business" }} />
|
||||||
|
<BusinessForm
|
||||||
|
mode="edit"
|
||||||
|
businessId={id}
|
||||||
|
scrollPadding={scrollPadding}
|
||||||
|
onSaved={() => {
|
||||||
|
Alert.alert("Saved", "Business updated", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
Alert.alert("Deleted", "Business removed", [
|
||||||
|
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { router, Stack } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
export default function NewBusinessScreen() {
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||||
|
<BusinessForm
|
||||||
|
mode="create"
|
||||||
|
scrollPadding={scrollPadding}
|
||||||
|
onSaved={() => {
|
||||||
|
Alert.alert("Business created", "Your business has been saved.", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function ClientDetailScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createClientDetailStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const clientQuery = api.clients.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid client" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading client…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = clientQuery.data;
|
||||||
|
if (!client) {
|
||||||
|
return <LoadingScreen message="Client not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = client.invoices ?? [];
|
||||||
|
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
|
const currency = client.currency ?? "USD";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.name}>{client.name}</Text>
|
||||||
|
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
|
||||||
|
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Summary">
|
||||||
|
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
|
||||||
|
<DetailRow label="Invoices" value={String(invoices.length)} />
|
||||||
|
{client.defaultHourlyRate != null ? (
|
||||||
|
<DetailRow
|
||||||
|
label="Default rate"
|
||||||
|
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(client.addressLine1 || client.city || client.state) && (
|
||||||
|
<Card title="Address">
|
||||||
|
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
|
||||||
|
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
|
||||||
|
{(client.city || client.state || client.postalCode) && (
|
||||||
|
<Text style={styles.body}>
|
||||||
|
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="Invoices">
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<Text style={styles.muted}>No invoices for this client yet.</Text>
|
||||||
|
) : (
|
||||||
|
invoices.map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={invoice.id}
|
||||||
|
style={styles.invoiceRow}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.invoiceMeta}>
|
||||||
|
<Text style={styles.invoiceTitle}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.invoiceRight}>
|
||||||
|
<Text style={styles.invoiceAmount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title="Edit client"
|
||||||
|
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="New invoice"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/invoices/new")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={detailStyles.row}>
|
||||||
|
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.foreground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
invoiceRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
invoiceMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
invoiceTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
invoiceRight: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
invoiceAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { ClientForm } from "@/components/clients/ClientForm";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
export default function EditClientScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Client" }} />
|
||||||
|
<ClientForm
|
||||||
|
mode="edit"
|
||||||
|
clientId={id}
|
||||||
|
scrollPadding={scrollPadding}
|
||||||
|
onSaved={() => {
|
||||||
|
Alert.alert("Saved", "Client updated", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
Alert.alert("Deleted", "Client removed", [
|
||||||
|
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { router, Stack } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { ClientForm } from "@/components/clients/ClientForm";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
export default function NewClientScreen() {
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||||
|
<ClientForm
|
||||||
|
mode="create"
|
||||||
|
scrollPadding={scrollPadding}
|
||||||
|
onSaved={() => {
|
||||||
|
Alert.alert("Client created", "Your client has been saved.", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type EntityTab = "clients" | "businesses";
|
||||||
|
|
||||||
|
const tabs: Array<{ label: string; value: EntityTab }> = [
|
||||||
|
{ label: "Clients", value: "clients" },
|
||||||
|
{ label: "Businesses", value: "businesses" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EntitiesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createEntitiesStyles);
|
||||||
|
const [tab, setTab] = useState<EntityTab>("clients");
|
||||||
|
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const deleteClient = api.clients.delete.useMutation({
|
||||||
|
onSuccess: () => void clientsQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteBusiness = api.businesses.delete.useMutation({
|
||||||
|
onSuccess: () => void businessesQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||||
|
const isLoading =
|
||||||
|
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingScreen message="Loading…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load {tab}</Text>
|
||||||
|
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: string, name: string) {
|
||||||
|
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
if (tab === "clients") deleteClient.mutate({ id });
|
||||||
|
else deleteBusiness.mutate({ id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Entities"
|
||||||
|
subtitle="Clients you bill and businesses you send from"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={refresh}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.tabScroll}
|
||||||
|
contentContainerStyle={styles.tabs}
|
||||||
|
>
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<FilterChip
|
||||||
|
key={item.value}
|
||||||
|
label={item.label}
|
||||||
|
active={tab === item.value}
|
||||||
|
onPress={() => setTab(item.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{tab === "clients" ? (
|
||||||
|
clients.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyTitle}>No clients yet</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Add a client to start creating invoices.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
clients.map((client) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={client.id}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(client.id, client.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||||
|
>
|
||||||
|
<GlassSurface style={styles.card}>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<Text style={styles.name}>{client.name}</Text>
|
||||||
|
{client.email ? (
|
||||||
|
<Text style={styles.meta}>{client.email}</Text>
|
||||||
|
) : null}
|
||||||
|
{client.defaultHourlyRate != null ? (
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||||
|
/hr
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
) : businesses.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyTitle}>No businesses yet</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Add your business profile for invoices and email sending.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
businesses.map((business) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={business.id}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(business.id, business.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||||
|
>
|
||||||
|
<GlassSurface style={styles.card}>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.name}>{business.name}</Text>
|
||||||
|
{business.isDefault ? (
|
||||||
|
<Text style={styles.badge}>Default</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{business.nickname ? (
|
||||||
|
<Text style={styles.meta}>{business.nickname}</Text>
|
||||||
|
) : null}
|
||||||
|
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
<FloatingActionButton
|
||||||
|
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
tab === "clients"
|
||||||
|
? "/(app)/entities/clients/new"
|
||||||
|
: "/(app)/entities/businesses/new",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
tabScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.md,
|
||||||
|
},
|
||||||
|
card: {},
|
||||||
|
cardInner: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
nameRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.primary,
|
||||||
|
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { Screen } from "@/components/Screen";
|
||||||
|
import { StatCard } from "@/components/StatCard";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useSession } from "@/contexts/AuthContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ActionItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
tone: "warning" | "primary" | "success";
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createDashboardStyles);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const statsQuery = api.dashboard.getStats.useQuery();
|
||||||
|
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
|
||||||
|
|
||||||
|
if (statsQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading home…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsQuery.error && !statsQuery.data) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Screen>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load home</Text>
|
||||||
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
if (!stats) {
|
||||||
|
return <LoadingScreen message="Loading home…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const running = runningQuery.data;
|
||||||
|
const runningClient = running?.client?.name ?? "No client";
|
||||||
|
const monthInvoices = stats.monthInvoices ?? [];
|
||||||
|
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
|
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
||||||
|
const drafts = stats.recentInvoices.filter((invoice) => invoice.status === "draft");
|
||||||
|
const pendingInvoices = monthInvoices.filter((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return status === "sent" || status === "overdue";
|
||||||
|
});
|
||||||
|
const overdueInvoices = monthInvoices.filter((invoice) => getInvoiceStatus(invoice) === "overdue");
|
||||||
|
const displayName = session?.user.name?.trim();
|
||||||
|
const firstName =
|
||||||
|
(displayName ? displayName.split(/\s+/)[0] : undefined) ??
|
||||||
|
session?.user.email?.split("@")[0] ??
|
||||||
|
"there";
|
||||||
|
|
||||||
|
const actionItems: ActionItem[] = [
|
||||||
|
...(running
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "running",
|
||||||
|
title: "Timer running",
|
||||||
|
detail: `${formatElapsedHoursMinutes(runningElapsed)} on ${runningClient}`,
|
||||||
|
icon: "timer-outline" as const,
|
||||||
|
tone: "success" as const,
|
||||||
|
onPress: () => router.push("/(app)/timer"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(overdueInvoices.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "overdue",
|
||||||
|
title: `${overdueInvoices.length} overdue ${overdueInvoices.length === 1 ? "invoice" : "invoices"}`,
|
||||||
|
detail: `${formatCurrency(overdueInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} needs follow-up`,
|
||||||
|
icon: "alert-circle-outline" as const,
|
||||||
|
tone: "warning" as const,
|
||||||
|
onPress: () => router.push("/(app)/invoices"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(drafts.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "drafts",
|
||||||
|
title: `${drafts.length} draft ${drafts.length === 1 ? "invoice" : "invoices"}`,
|
||||||
|
detail: "Review and send when ready",
|
||||||
|
icon: "document-text-outline" as const,
|
||||||
|
tone: "primary" as const,
|
||||||
|
onPress: () => router.push("/(app)/invoices"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(pendingInvoices.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "pending",
|
||||||
|
title: `${pendingInvoices.length} awaiting payment`,
|
||||||
|
detail: `${formatCurrency(pendingInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} outstanding this month`,
|
||||||
|
icon: "card-outline" as const,
|
||||||
|
tone: "primary" as const,
|
||||||
|
onPress: () => router.push("/(app)/invoices"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (actionItems.length === 0) {
|
||||||
|
actionItems.push({
|
||||||
|
key: "clear",
|
||||||
|
title: "No urgent action items",
|
||||||
|
detail: "You are clear for the moment",
|
||||||
|
icon: "checkmark-circle-outline",
|
||||||
|
tone: "success",
|
||||||
|
onPress: () => router.push("/(app)/invoices"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() => Promise.all([statsQuery.refetch(), runningQuery.refetch()])}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.quickActions}>
|
||||||
|
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
|
||||||
|
<Button
|
||||||
|
title="Invoices"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/invoices")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Reports"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/more/reports" as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Action items">
|
||||||
|
<View style={styles.actionList}>
|
||||||
|
{actionItems.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
key={item.key}
|
||||||
|
onPress={item.onPress}
|
||||||
|
style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.actionIcon,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
item.tone === "warning"
|
||||||
|
? colors.warningBg
|
||||||
|
: item.tone === "success"
|
||||||
|
? colors.successBg
|
||||||
|
: colors.muted,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={item.icon}
|
||||||
|
size={20}
|
||||||
|
color={
|
||||||
|
item.tone === "warning"
|
||||||
|
? colors.warning
|
||||||
|
: item.tone === "success"
|
||||||
|
? colors.success
|
||||||
|
: colors.primary
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionCopy}>
|
||||||
|
<Text style={styles.actionTitle}>{item.title}</Text>
|
||||||
|
<Text style={styles.actionDetail}>{item.detail}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push("/(app)/timer")}
|
||||||
|
>
|
||||||
|
<GlassSurface style={styles.runningGlass}>
|
||||||
|
<View style={styles.runningRow}>
|
||||||
|
<View style={styles.runningDot} />
|
||||||
|
<View style={styles.runningMeta}>
|
||||||
|
<Text style={styles.runningTitle}>
|
||||||
|
{resolveClockDescription(running.description)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.runningSub}>
|
||||||
|
{runningClient}
|
||||||
|
{running.invoice
|
||||||
|
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.runningTime}>
|
||||||
|
{formatElapsedHoursMinutes(runningElapsed)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={now.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View style={styles.monthSummary}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.monthValue}>{formatCurrency(monthTotal)}</Text>
|
||||||
|
<Text style={styles.monthLabel}>
|
||||||
|
{monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title="New invoice"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/invoices/new")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.monthList}>
|
||||||
|
{monthInvoices.slice(0, 4).map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
key={invoice.id}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
style={({ pressed }) => [styles.monthInvoiceRow, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<View style={styles.invoiceMeta}>
|
||||||
|
<Text style={styles.invoiceTitle}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.invoiceRight}>
|
||||||
|
<Text style={styles.invoiceAmount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{monthInvoices.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No invoices in this month yet.</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{stats.currentDraft ? (
|
||||||
|
<GlassSurface style={styles.draftGlass}>
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Current draft, ${
|
||||||
|
stats.currentDraft.client?.name ?? "Client"
|
||||||
|
}, ${formatCurrency(stats.currentDraft.totalAmount)}, ${stats.currentDraft.totalHours.toFixed(1)} hours logged`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={styles.draftBanner}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.draftCopy}>
|
||||||
|
<Text style={styles.draftTitle}>Current draft</Text>
|
||||||
|
<Text style={styles.draftText}>
|
||||||
|
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
|
||||||
|
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
|
||||||
|
{stats.currentDraft.totalHours.toFixed(1)}h logged
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statCell}>
|
||||||
|
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCell}>
|
||||||
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCell}>
|
||||||
|
<StatCard label="Overdue" value={String(stats.overdueCount)} />
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Clients, ${stats.totalClients}`}
|
||||||
|
style={styles.statCell}
|
||||||
|
onPress={() => router.push("/(app)/entities")}
|
||||||
|
>
|
||||||
|
<StatCard label="Clients" value={String(stats.totalClients)} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Revenue trend">
|
||||||
|
<View style={styles.chart}>
|
||||||
|
{stats.revenueChartData.map((point) => {
|
||||||
|
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
|
||||||
|
return (
|
||||||
|
<View key={point.month} style={styles.chartColumn}>
|
||||||
|
<View style={styles.chartBarTrack}>
|
||||||
|
<View style={[styles.chartBar, { height: barHeight }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Recent invoices">
|
||||||
|
{stats.recentInvoices.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
||||||
|
) : (
|
||||||
|
stats.recentInvoices.map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${invoice.invoicePrefix}${invoice.invoiceNumber}, ${
|
||||||
|
invoice.client?.name ?? "Client"
|
||||||
|
}, ${formatDate(invoice.issueDate)}, ${formatCurrency(
|
||||||
|
invoice.totalAmount,
|
||||||
|
invoice.currency,
|
||||||
|
)}, ${status}`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
key={invoice.id}
|
||||||
|
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.invoiceMeta}>
|
||||||
|
<Text style={styles.invoiceTitle}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
|
||||||
|
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.invoiceRight}>
|
||||||
|
<Text style={styles.invoiceAmount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
actionList: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: 58,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
actionIcon: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
actionCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
actionTitle: {
|
||||||
|
color: colors.foreground,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actionDetail: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
runningGlass: {
|
||||||
|
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
|
||||||
|
},
|
||||||
|
runningRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
runningDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
runningMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
runningTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
runningSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
runningTime: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.success,
|
||||||
|
},
|
||||||
|
monthSummary: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
monthValue: {
|
||||||
|
color: colors.foreground,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 30,
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
monthList: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
monthInvoiceRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
draftGlass: {
|
||||||
|
borderColor: isDark ? "rgba(59, 130, 246, 0.32)" : "#BFDBFE",
|
||||||
|
},
|
||||||
|
draftBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
draftCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
draftTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
draftText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.md,
|
||||||
|
alignContent: "flex-start",
|
||||||
|
},
|
||||||
|
statCell: {
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: "47%",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
chartColumn: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
chartBarTrack: {
|
||||||
|
width: "100%",
|
||||||
|
height: 80,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: "70%",
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
},
|
||||||
|
chartLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
recentRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
invoiceMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
invoiceTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
invoiceClient: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
invoiceDate: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
invoiceRight: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
invoiceAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||||
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
|
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function InvoiceDetailScreen() {
|
||||||
|
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
const [section, setSection] = useState<InvoiceViewSection>("details");
|
||||||
|
|
||||||
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewInput = useMemo(
|
||||||
|
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||||
|
[invoiceQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.error || !invoiceQuery.data) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load invoice</Text>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||||
|
</Text>
|
||||||
|
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||||
|
</View>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
|
function openSendScreen() {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invoice.items.length === 0) {
|
||||||
|
Alert.alert(
|
||||||
|
"No line items",
|
||||||
|
"Add line items or clock time to this invoice before sending.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptPaymentReminder() {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client before sending payment reminders.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Send payment reminder",
|
||||||
|
`Email a payment reminder to ${clientEmail}?`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Send",
|
||||||
|
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptStatusChange(current: InvoiceStatus) {
|
||||||
|
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||||
|
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||||
|
if (current !== "sent" && current !== "overdue") {
|
||||||
|
options.push({ label: "Mark as sent", status: "sent" });
|
||||||
|
}
|
||||||
|
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
Alert.alert("Update status", "Choose a new status", [
|
||||||
|
...options.map((option) => ({
|
||||||
|
text: option.label,
|
||||||
|
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||||
|
})),
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.headerMeta}>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.total}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<InvoiceViewChips
|
||||||
|
section={section}
|
||||||
|
onSectionChange={setSection}
|
||||||
|
status={status}
|
||||||
|
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||||
|
onSend={openSendScreen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{section === "preview" ? (
|
||||||
|
<Card title="PDF preview">
|
||||||
|
<InvoicePdfPreview input={previewInput} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card title="Details">
|
||||||
|
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
|
||||||
|
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
|
||||||
|
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||||
|
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||||
|
<DetailRow label="Currency" value={invoice.currency} />
|
||||||
|
{invoice.taxRate > 0 ? (
|
||||||
|
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||||||
|
) : null}
|
||||||
|
{invoice.status === "draft" && invoice.sendReminderAt ? (
|
||||||
|
<DetailRow
|
||||||
|
label="Send reminder"
|
||||||
|
value={
|
||||||
|
new Date(invoice.sendReminderAt) <= new Date()
|
||||||
|
? "Due now"
|
||||||
|
: formatDate(invoice.sendReminderAt)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Line items">
|
||||||
|
{invoice.items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyLines}>
|
||||||
|
No line items yet. Clock time to this invoice from the Timer tab, or edit to
|
||||||
|
add lines manually.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
invoice.items.map((item) => {
|
||||||
|
const line = (
|
||||||
|
<View style={styles.lineItem}>
|
||||||
|
<View style={styles.lineMeta}>
|
||||||
|
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||||
|
<Text style={styles.lineSub}>
|
||||||
|
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||||
|
{formatCurrency(item.rate, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.lineAmount}>
|
||||||
|
{formatCurrency(item.amount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
return <View key={item.id}>{line}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
backgroundColor={colors.card}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<InvoiceTotals
|
||||||
|
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||||
|
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||||||
|
taxAmount={
|
||||||
|
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
|
||||||
|
}
|
||||||
|
total={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{invoice.notes ? (
|
||||||
|
<Card title="Notes">
|
||||||
|
<Text style={styles.notes}>{invoice.notes}</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<InvoiceDetailActions
|
||||||
|
status={status}
|
||||||
|
clientEmail={clientEmail}
|
||||||
|
onPaymentReminder={
|
||||||
|
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
|
||||||
|
}
|
||||||
|
paymentReminderLoading={sendPaymentReminder.isPending}
|
||||||
|
onUpdateStatus={() => promptStatusChange(status)}
|
||||||
|
updateStatusLoading={updateStatus.isPending}
|
||||||
|
onTrackTime={() =>
|
||||||
|
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={detailStyles.row}>
|
||||||
|
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 26,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
fontSize: 28,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
lineItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
lineMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
lineDescription: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
lineSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
lineAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
emptyLines: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export default function InvoicesLayout() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
headerStyle: { backgroundColor: colors.cardGlass },
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerTintColor: colors.foreground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Invoices",
|
||||||
|
headerShown: false,
|
||||||
|
statusBarTranslucent: true,
|
||||||
|
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="new"
|
||||||
|
options={{
|
||||||
|
title: "New invoice",
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[id]"
|
||||||
|
options={{
|
||||||
|
title: "Invoice",
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="send/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Send invoice",
|
||||||
|
headerBackTitle: "Invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="edit/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Edit invoice",
|
||||||
|
headerBackTitle: "Invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
InvoiceEditorSectionTabs,
|
||||||
|
type InvoiceEditorSection,
|
||||||
|
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||||
|
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||||
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
|
||||||
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||||
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||||
|
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function InvoiceEditScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const [businessId, setBusinessId] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [dueDate, setDueDate] = useState(() => new Date());
|
||||||
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
|
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||||
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||||
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
if (!invoice) return;
|
||||||
|
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
|
||||||
|
setClientId(invoice.clientId);
|
||||||
|
setNotes(invoice.notes ?? "");
|
||||||
|
setDueDate(new Date(invoice.dueDate));
|
||||||
|
setTaxRate(String(invoice.taxRate));
|
||||||
|
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||||
|
setItems(
|
||||||
|
invoice.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: new Date(item.date),
|
||||||
|
description: item.description,
|
||||||
|
hours: String(item.hours),
|
||||||
|
rate: String(item.rate),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [invoiceQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId || !businessesQuery.data?.length) return;
|
||||||
|
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||||
|
}, [businessId, businessesQuery.data]);
|
||||||
|
|
||||||
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
Alert.alert("Saved", "Invoice updated", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
const isDraft = invoice?.status === "draft";
|
||||||
|
|
||||||
|
const businessOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(businessesQuery.data ?? []).map((business) => ({
|
||||||
|
label: business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
[businessesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(clientsQuery.data ?? []).map((client) => ({
|
||||||
|
label: client.name,
|
||||||
|
value: client.id,
|
||||||
|
})),
|
||||||
|
[clientsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||||
|
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
||||||
|
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce((sum, item) => {
|
||||||
|
const hours = Number(item.hours) || 0;
|
||||||
|
const rate = Number(item.rate) || 0;
|
||||||
|
return sum + hours * rate;
|
||||||
|
}, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedTaxRate = Number(taxRate) || 0;
|
||||||
|
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||||
|
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
||||||
|
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||||
|
const clientError = isDraft && !clientId ? "Select a client" : undefined;
|
||||||
|
const canSave = isDraft
|
||||||
|
? !lineItemsError && !taxError && !businessError && !clientError
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const previewInput = useMemo(() => {
|
||||||
|
if (!invoice) return null;
|
||||||
|
return buildPreviewPdfInput({
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
invoicePrefix: invoice.invoicePrefix,
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
issueDate: new Date(invoice.issueDate),
|
||||||
|
dueDate,
|
||||||
|
status: invoice.status as "draft" | "sent" | "paid",
|
||||||
|
notes,
|
||||||
|
taxRate: parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!canSave) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isDraft && sendReminderAt) {
|
||||||
|
const granted = await ensureNotificationPermissions();
|
||||||
|
if (!granted) {
|
||||||
|
Alert.alert(
|
||||||
|
"Notifications disabled",
|
||||||
|
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedItems: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
parsedItems.push({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description.trim(),
|
||||||
|
hours: Number(item.hours),
|
||||||
|
rate: Number(item.rate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvoice.mutate({
|
||||||
|
id,
|
||||||
|
notes,
|
||||||
|
dueDate,
|
||||||
|
sendReminderAt,
|
||||||
|
...(isDraft
|
||||||
|
? {
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
taxRate: parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
items: parsedItems,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientName}>
|
||||||
|
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||||
|
|
||||||
|
{section === "preview" ? (
|
||||||
|
<Card title="PDF preview">
|
||||||
|
<InvoicePdfPreview input={previewInput} />
|
||||||
|
</Card>
|
||||||
|
) : section === "setup" ? (
|
||||||
|
<Card title="Invoice setup">
|
||||||
|
<InvoiceSetupForm
|
||||||
|
businessId={businessId}
|
||||||
|
onBusinessIdChange={setBusinessId}
|
||||||
|
businessOptions={businessOptions}
|
||||||
|
businessError={businessError}
|
||||||
|
businessReadOnly={!isDraft}
|
||||||
|
clientId={clientId}
|
||||||
|
onClientIdChange={setClientId}
|
||||||
|
clientOptions={clientOptions}
|
||||||
|
clientError={clientError}
|
||||||
|
clientReadOnly={!isDraft}
|
||||||
|
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||||
|
invoiceNumberReadOnly
|
||||||
|
issueDate={new Date(invoice.issueDate)}
|
||||||
|
issueDateReadOnly
|
||||||
|
dueDate={dueDate}
|
||||||
|
onDueDateChange={setDueDate}
|
||||||
|
taxRate={taxRate}
|
||||||
|
onTaxRateChange={isDraft ? setTaxRate : undefined}
|
||||||
|
taxRateReadOnly={!isDraft}
|
||||||
|
notes={notes}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
sendReminderAt={sendReminderAt}
|
||||||
|
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
|
||||||
|
showSendReminder={isDraft}
|
||||||
|
/>
|
||||||
|
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card title="Line items">
|
||||||
|
{!isDraft ? (
|
||||||
|
<Text style={styles.lockedHint}>
|
||||||
|
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||||
|
screen to edit entries.
|
||||||
|
</Text>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyLines}>
|
||||||
|
No line items yet. Add lines here or clock time to this invoice from the
|
||||||
|
Timer tab.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<LineItemEditor
|
||||||
|
key={item.id ?? `new-${index}`}
|
||||||
|
index={index}
|
||||||
|
item={item}
|
||||||
|
currency={currency}
|
||||||
|
isLast={index === items.length - 1}
|
||||||
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||||
|
readOnly={!isDraft}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isDraft ? (
|
||||||
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<InvoiceTotals
|
||||||
|
subtotal={formatCurrency(subtotal, currency)}
|
||||||
|
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||||
|
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
||||||
|
total={formatCurrency(total, currency)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<InvoiceEditorFooter
|
||||||
|
primaryTitle="Save changes"
|
||||||
|
onPrimary={handleSave}
|
||||||
|
primaryLoading={updateInvoice.isPending}
|
||||||
|
primaryDisabled={!canSave}
|
||||||
|
secondary={
|
||||||
|
status !== "paid"
|
||||||
|
? {
|
||||||
|
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
||||||
|
subtitle: clientEmail
|
||||||
|
? items.length === 0
|
||||||
|
? "Add line items before sending"
|
||||||
|
: `Review PDF and email to ${clientEmail}`
|
||||||
|
: "Add a client email first",
|
||||||
|
icon: "mail-outline",
|
||||||
|
onPress: () => {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
Alert.alert(
|
||||||
|
"No line items",
|
||||||
|
"Add line items or clock time to this invoice before sending.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||||
|
},
|
||||||
|
disabled: !clientEmail || items.length === 0,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
lockedHint: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyLines: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
addLine: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
addLineText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Sent", value: "sent" },
|
||||||
|
{ label: "Paid", value: "paid" },
|
||||||
|
{ label: "Overdue", value: "overdue" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InvoicesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createInvoicesStyles);
|
||||||
|
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const invoicesQuery = api.invoices.getAll.useQuery();
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invoices.getAll.invalidate();
|
||||||
|
utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invoices.getAll.invalidate();
|
||||||
|
utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Delete failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invoicesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoices…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoicesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||||
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
return getInvoiceStatus(invoice) === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
|
||||||
|
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||||
|
|
||||||
|
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||||
|
if (current !== "sent" && current !== "overdue") {
|
||||||
|
options.push({ label: "Mark as sent", status: "sent" });
|
||||||
|
}
|
||||||
|
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||||
|
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
Alert.alert("Update status", "Choose a new status", [
|
||||||
|
...options.map((option) => ({
|
||||||
|
text: option.label,
|
||||||
|
onPress: () => {
|
||||||
|
updateStatus.mutate({ id: invoiceId, status: option.status });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(invoiceId: string, label: string) {
|
||||||
|
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() => invoicesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.filterScroll}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
{filters.map((item) => (
|
||||||
|
<FilterChip
|
||||||
|
key={item.label}
|
||||||
|
label={item.label}
|
||||||
|
active={filter === item.value}
|
||||||
|
onPress={() => setFilter(item.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Tap + to create your first invoice, or pull to refresh.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
invoices.map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
|
||||||
|
},
|
||||||
|
...(status === "draft"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "send",
|
||||||
|
label: "Send",
|
||||||
|
icon: "send-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(invoice.id, label),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
icon: "flag-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
onPress: () => promptStatusChange(invoice.id, status),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
key={invoice.id}
|
||||||
|
actions={actions}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||||
|
>
|
||||||
|
<GlassSurface style={styles.card}>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.cardTop}>
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||||
|
<Text style={styles.clientName}>
|
||||||
|
{invoice.client?.name ?? "Client"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.amount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardBottom}>
|
||||||
|
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
<FloatingActionButton
|
||||||
|
accessibilityLabel="Create invoice"
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert("Create invoice", "Choose how to start", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "With line items",
|
||||||
|
onPress: () => router.push("/(app)/invoices/new"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Blank (for timer)",
|
||||||
|
onPress: () => router.push("/(app)/invoices/new?blank=1"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
filterScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.md,
|
||||||
|
},
|
||||||
|
card: {},
|
||||||
|
cardInner: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cardTop: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cardMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
cardBottom: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
InvoiceEditorSectionTabs,
|
||||||
|
type InvoiceEditorSection,
|
||||||
|
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||||
|
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||||
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
isValidTaxRate,
|
||||||
|
useFieldVisibility,
|
||||||
|
validateLineItems,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||||
|
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function NewInvoiceScreen() {
|
||||||
|
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
const { blank } = useLocalSearchParams<{ blank?: string }>();
|
||||||
|
const isBlank = blank === "1" || blank === "true";
|
||||||
|
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const [businessId, setBusinessId] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||||
|
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||||
|
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
|
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||||
|
isBlank
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: "0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId || !businessesQuery.data?.length) return;
|
||||||
|
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||||
|
}, [businessId, businessesQuery.data]);
|
||||||
|
|
||||||
|
const businessOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(businessesQuery.data ?? []).map((business) => ({
|
||||||
|
label: business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
[businessesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(clientsQuery.data ?? []).map((client) => ({
|
||||||
|
label: client.name,
|
||||||
|
value: client.id,
|
||||||
|
})),
|
||||||
|
[clientsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||||
|
const currency = selectedClient?.currency ?? "USD";
|
||||||
|
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedClient?.defaultHourlyRate) return;
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item, index) =>
|
||||||
|
index === 0 && (item.rate === "0" || item.rate === "")
|
||||||
|
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
|
||||||
|
|
||||||
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
|
onSuccess: (invoice) => {
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
Alert.alert("Invoice created", "Your draft invoice is ready.", [
|
||||||
|
{
|
||||||
|
text: "View invoice",
|
||||||
|
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce((sum, item) => {
|
||||||
|
const hours = Number(item.hours) || 0;
|
||||||
|
const rate = Number(item.rate) || 0;
|
||||||
|
return sum + hours * rate;
|
||||||
|
}, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedTaxRate = Number(taxRate) || 0;
|
||||||
|
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
const previewInput = useMemo(
|
||||||
|
() =>
|
||||||
|
buildPreviewPdfInput({
|
||||||
|
invoiceNumber,
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
taxRate: parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
notes,
|
||||||
|
items,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
invoiceNumber,
|
||||||
|
resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
notes,
|
||||||
|
items,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const businessError = resolvedBusinessId ? undefined : "Select a business";
|
||||||
|
const clientError = clientId ? undefined : "Select a client";
|
||||||
|
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||||
|
? undefined
|
||||||
|
: "Invoice number is required";
|
||||||
|
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
||||||
|
const lineItemsError = validateLineItems(items);
|
||||||
|
const canCreate =
|
||||||
|
businessOptions.length > 0 &&
|
||||||
|
clientOptions.length > 0 &&
|
||||||
|
!businessError &&
|
||||||
|
!clientError &&
|
||||||
|
!invoiceNumberError &&
|
||||||
|
!taxError &&
|
||||||
|
!lineItemsError;
|
||||||
|
|
||||||
|
if (businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
|
touch("lineItems");
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
touch("lineItems");
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
touch("lineItems");
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
touch("lineItems");
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
markSubmitted();
|
||||||
|
if (!canCreate) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const parsedItems: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
parsedItems.push({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description.trim(),
|
||||||
|
hours: Number(item.hours),
|
||||||
|
rate: Number(item.rate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvoice.mutate({
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
invoiceNumber: invoiceNumber.trim(),
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
notes,
|
||||||
|
taxRate: Number(taxRate),
|
||||||
|
currency,
|
||||||
|
items: parsedItems,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
title: isBlank ? "Blank invoice" : "New invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||||
|
|
||||||
|
{section === "preview" ? (
|
||||||
|
<Card title="PDF preview">
|
||||||
|
<InvoicePdfPreview input={previewInput} />
|
||||||
|
</Card>
|
||||||
|
) : section === "setup" ? (
|
||||||
|
<Card title="Invoice setup">
|
||||||
|
{clientOptions.length === 0 || businessOptions.length === 0 ? (
|
||||||
|
<View style={styles.noEntities}>
|
||||||
|
<Text style={styles.noEntitiesText}>
|
||||||
|
{businessOptions.length === 0
|
||||||
|
? "Add a business before creating an invoice."
|
||||||
|
: "Add a client before creating an invoice."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
businessOptions.length === 0
|
||||||
|
? "/(app)/entities/businesses/new"
|
||||||
|
: "/(app)/entities/clients/new",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<InvoiceSetupForm
|
||||||
|
businessId={businessId}
|
||||||
|
onBusinessIdChange={setBusinessId}
|
||||||
|
businessOptions={businessOptions}
|
||||||
|
businessError={visible("business") ? businessError : undefined}
|
||||||
|
onBusinessBlur={() => touch("business")}
|
||||||
|
clientId={clientId}
|
||||||
|
onClientIdChange={setClientId}
|
||||||
|
clientOptions={clientOptions}
|
||||||
|
clientError={visible("client") ? clientError : undefined}
|
||||||
|
onClientBlur={() => touch("client")}
|
||||||
|
invoiceNumber={invoiceNumber}
|
||||||
|
onInvoiceNumberChange={setInvoiceNumber}
|
||||||
|
invoiceNumberError={
|
||||||
|
visible("invoiceNumber") ? invoiceNumberError : undefined
|
||||||
|
}
|
||||||
|
onInvoiceNumberBlur={() => touch("invoiceNumber")}
|
||||||
|
issueDate={issueDate}
|
||||||
|
onIssueDateChange={setIssueDate}
|
||||||
|
dueDate={dueDate}
|
||||||
|
onDueDateChange={setDueDate}
|
||||||
|
taxRate={taxRate}
|
||||||
|
onTaxRateChange={setTaxRate}
|
||||||
|
taxRateError={visible("taxRate") ? taxError : undefined}
|
||||||
|
onTaxRateBlur={() => touch("taxRate")}
|
||||||
|
notes={notes}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card title="Line items">
|
||||||
|
{isBlank && items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyLines}>
|
||||||
|
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||||
|
or add lines here.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<LineItemEditor
|
||||||
|
key={`new-${index}`}
|
||||||
|
index={index}
|
||||||
|
item={item}
|
||||||
|
currency={currency}
|
||||||
|
isLast={index === items.length - 1}
|
||||||
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={() => duplicateItem(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<InvoiceTotals
|
||||||
|
subtotal={formatCurrency(subtotal, currency)}
|
||||||
|
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||||
|
taxAmount={
|
||||||
|
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
|
||||||
|
}
|
||||||
|
total={formatCurrency(total, currency)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{visible("lineItems") && lineItemsError ? (
|
||||||
|
<Text selectable style={styles.error}>{lineItemsError}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<InvoiceEditorFooter
|
||||||
|
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||||
|
onPrimary={handleCreate}
|
||||||
|
primaryLoading={createInvoice.isPending}
|
||||||
|
primaryDisabled={!canCreate}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
noEntities: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
noEntitiesText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
emptyLines: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
addLine: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
addLineText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function InvoiceSendScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createSendStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
|
||||||
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
Alert.alert("Invoice sent", data.message, [
|
||||||
|
{ text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewInput = useMemo(
|
||||||
|
() =>
|
||||||
|
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||||
|
[invoiceQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoiceQuery.data) {
|
||||||
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
const businessName = invoice.business?.name ?? "Your business";
|
||||||
|
const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invoice.items.length === 0) {
|
||||||
|
Alert.alert(
|
||||||
|
"No line items",
|
||||||
|
"Add line items or clock time to this invoice before sending.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInvoice.mutate({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
customMessage: customMessage.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card title="Email summary">
|
||||||
|
<SummaryRow label="From" value={businessName} />
|
||||||
|
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
|
||||||
|
<SummaryRow
|
||||||
|
label="Invoice"
|
||||||
|
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||||
|
/>
|
||||||
|
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||||
|
<SummaryRow
|
||||||
|
label="Amount"
|
||||||
|
value={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
bold
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="PDF attachment">
|
||||||
|
<InvoicePdfPreview input={previewInput} height={480} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Message">
|
||||||
|
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
|
||||||
|
Optional note included in the email body.
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
label="Personal message"
|
||||||
|
value={customMessage}
|
||||||
|
onChangeText={setCustomMessage}
|
||||||
|
placeholder="Thanks for your business!"
|
||||||
|
multiline
|
||||||
|
style={styles.messageInput}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={sendLabel}
|
||||||
|
onPress={handleSend}
|
||||||
|
loading={sendInvoice.isPending}
|
||||||
|
disabled={!clientEmail || invoice.items.length === 0}
|
||||||
|
/>
|
||||||
|
{!clientEmail ? (
|
||||||
|
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||||
|
Add a client email address before sending.
|
||||||
|
</Text>
|
||||||
|
) : invoice.items.length === 0 ? (
|
||||||
|
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||||
|
Add line items before sending this invoice.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={summaryStyles.row}>
|
||||||
|
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
summaryStyles.value,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
bold && summaryStyles.bold,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSendStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
messageHint: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
messageInput: {
|
||||||
|
minHeight: 96,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function MoreLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function ExpenseDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>({
|
||||||
|
description: "",
|
||||||
|
amountText: "",
|
||||||
|
date: new Date(),
|
||||||
|
category: "",
|
||||||
|
businessId: "",
|
||||||
|
clientId: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseQuery = api.expenses.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const updateExpense = api.expenses.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
await expenseQuery.refetch();
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
const expense = expenseQuery.data;
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
|
||||||
|
async function attachAndScan(fromCamera: boolean) {
|
||||||
|
if (!id || !expense) return;
|
||||||
|
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: id,
|
||||||
|
filename: result.image.filename,
|
||||||
|
mimeType: result.image.mimeType,
|
||||||
|
data: result.image.base64,
|
||||||
|
});
|
||||||
|
|
||||||
|
setForm(
|
||||||
|
expenseToForm(expense, {
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setEditing(true);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt attached",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the owed items, apply the split amount, then save the expense."
|
||||||
|
: "We filled in what we could. Review and save to update this expense.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveEdits() {
|
||||||
|
if (!id) return;
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Invalid fields", "Description and amount are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExpense.mutate({
|
||||||
|
id,
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
category: form.category || undefined,
|
||||||
|
businessId: form.businessId || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
if (!expense) return;
|
||||||
|
setForm(expenseToForm(expense));
|
||||||
|
setReceiptSplit(null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expense…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
Expense not found
|
||||||
|
</Text>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={expense.description}
|
||||||
|
subtitle={formatDate(expense.date)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={form}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Save changes"
|
||||||
|
loading={updateExpense.isPending}
|
||||||
|
onPress={handleSaveEdits}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel edit"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaStack}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
{expense.category || "No category"}
|
||||||
|
{expense.business?.name ? ` · ${expense.business.name}` : ""}
|
||||||
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{expense.billable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.primary, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Billable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.reimbursable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{
|
||||||
|
color: colors.foreground,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Reimbursable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.taxDeductible ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.success, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Tax deductible
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{expense.notes ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{expense.notes}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Edit expense"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={startEditing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={[styles.section, { color: colors.foreground }]}>
|
||||||
|
Receipts ({expense.receipts.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{expense.receipts.map((receipt) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={receipt.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.receiptRow, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</Text>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Import photo"
|
||||||
|
variant="secondary"
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
amount: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
metaStack: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
receiptRow: {
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function expenseToForm(
|
||||||
|
expense: {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
date: Date | string;
|
||||||
|
category: string | null;
|
||||||
|
businessId: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean | null;
|
||||||
|
notes: string | null;
|
||||||
|
},
|
||||||
|
overrides: Partial<ExpenseFormState> = {},
|
||||||
|
): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
category: expense.category ?? "",
|
||||||
|
businessId: expense.businessId ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNotes(prefix: string, existing: string) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||||
|
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||||
|
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||||
|
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||||
|
|
||||||
|
export default function ExpensesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [filter, setFilter] = useState<ExpenseFilter>("all");
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
|
||||||
|
const deleteExpense = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => void utils.expenses.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenses = expensesQuery.data ?? [];
|
||||||
|
const filteredExpenses = useMemo(
|
||||||
|
() =>
|
||||||
|
expenses.filter((expense) => {
|
||||||
|
if (filter === "billable") return expense.billable;
|
||||||
|
if (filter === "receipts") return expense.receiptCount > 0;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[expenses, filter],
|
||||||
|
);
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const total = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + expense.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const billable = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const receiptCount = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { total, billable, receiptCount };
|
||||||
|
}, [filteredExpenses]);
|
||||||
|
const groupedExpenses = useMemo(
|
||||||
|
() => groupExpensesByMonth(filteredExpenses),
|
||||||
|
[filteredExpenses],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (expensesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expenses..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expensesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Expenses" subtitle="Expense tracking" />
|
||||||
|
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
|
||||||
|
Could not load expenses
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(expensesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<PageHeader
|
||||||
|
title="Expenses"
|
||||||
|
subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Add expense"
|
||||||
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() => expensesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expenses.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.emptyCard,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||||
|
No expenses yet
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Add expense"
|
||||||
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.summaryGrid}>
|
||||||
|
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
||||||
|
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
||||||
|
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
<FilterChip
|
||||||
|
label="All"
|
||||||
|
active={filter === "all"}
|
||||||
|
onPress={() => setFilter("all")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="Billable"
|
||||||
|
active={filter === "billable"}
|
||||||
|
onPress={() => setFilter("billable")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="With receipts"
|
||||||
|
active={filter === "receipts"}
|
||||||
|
onPress={() => setFilter("receipts")}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{filteredExpenses.length === 0 ? (
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
No expenses match this filter.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
groupedExpenses.map(([monthLabel, group]) => (
|
||||||
|
<View key={monthLabel} style={styles.monthGroup}>
|
||||||
|
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
{group.map((expense) => (
|
||||||
|
<ExpenseRow
|
||||||
|
key={expense.id}
|
||||||
|
expense={expense}
|
||||||
|
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.summaryTile,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
contentStyle={({ pressed }) => [
|
||||||
|
styles.row,
|
||||||
|
{ borderColor: colors.border },
|
||||||
|
pressed && styles.rowPressed,
|
||||||
|
]}
|
||||||
|
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||||
|
{expense.description}
|
||||||
|
</Text>
|
||||||
|
{expense.receiptCount ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.receiptPill,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||||
|
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||||
|
{expense.receiptCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||||
|
{formatDate(expense.date)}
|
||||||
|
{expense.category ? ` · ${expense.category}` : ""}
|
||||||
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.tagRow}>
|
||||||
|
{expense.billable ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||||
|
Billable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.reimbursable ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||||
|
Reimbursable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.taxDeductible ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.amountStack}>
|
||||||
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupExpensesByMonth(expenses: Expense[]) {
|
||||||
|
const groups = new Map<string, Expense[]>();
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const date = new Date(expense.date);
|
||||||
|
const key = date.toLocaleDateString(undefined, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(expense);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
||||||
|
const normalized = category?.toLowerCase() ?? "";
|
||||||
|
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
||||||
|
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
|
||||||
|
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
|
||||||
|
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
|
||||||
|
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
|
||||||
|
return "receipt-outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.82,
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
sub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
tagRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
amountStack: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summaryGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
summaryTile: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: "30%",
|
||||||
|
minWidth: 104,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 20,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
monthGroup: {
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0,
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.lg,
|
||||||
|
},
|
||||||
|
receiptPill: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
receiptPillText: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 17,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
defaultExpenseFormState,
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import {
|
||||||
|
scanReceiptImage,
|
||||||
|
type PickedReceiptImage,
|
||||||
|
type ReceiptScanResult,
|
||||||
|
} from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function NewExpenseScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
const defaultBusinessId = useMemo(
|
||||||
|
() =>
|
||||||
|
businesses.find((business) => business.isDefault)?.id ??
|
||||||
|
businesses[0]?.id ??
|
||||||
|
"",
|
||||||
|
[businesses],
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>(() =>
|
||||||
|
defaultExpenseFormState(),
|
||||||
|
);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [pendingReceipt, setPendingReceipt] =
|
||||||
|
useState<PickedReceiptImage | null>(null);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createExpense = api.expenses.create.useMutation();
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
async function runScan(fromCamera: boolean) {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: form.description,
|
||||||
|
amountText: form.amountText,
|
||||||
|
date: form.date,
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
businessId: current.businessId || defaultBusinessId,
|
||||||
|
}));
|
||||||
|
setPendingReceipt(result.image);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt scanned",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the items this person owes, then apply the split amount."
|
||||||
|
: result.amountText
|
||||||
|
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
|
||||||
|
: "Review the fields and enter the total before saving.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
Alert.alert("Description required", "Enter what this expense was for.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Amount required", "Enter a valid amount.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expense = await createExpense.mutateAsync({
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
currency: "USD",
|
||||||
|
category: form.category || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
businessId: form.businessId || defaultBusinessId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingReceipt) {
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: expense.id,
|
||||||
|
filename: pendingReceipt.filename,
|
||||||
|
mimeType: pendingReceipt.mimeType,
|
||||||
|
data: pendingReceipt.base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
router.replace(`/(app)/more/expenses/${expense.id}` as never);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not save expense",
|
||||||
|
err instanceof Error ? err.message : "Try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="New expense"
|
||||||
|
subtitle="Add a receipt, fill the details, and save it"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card title="Receipt">
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning..." : "Take photo"}
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon="camera-outline"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Choose photo"
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon="image-outline"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pendingReceipt ? (
|
||||||
|
<View style={[styles.notice, { backgroundColor: colors.successBg }]}>
|
||||||
|
<Text style={[styles.noticeText, { color: colors.success }]}>
|
||||||
|
Receipt attached. Review the details below before saving.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card title="Details">
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={{
|
||||||
|
...form,
|
||||||
|
businessId: form.businessId || defaultBusinessId,
|
||||||
|
}}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
notesLabel="Notes"
|
||||||
|
notesPlaceholder="Internal details"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View style={styles.saveActions}>
|
||||||
|
<Button
|
||||||
|
title="Save expense"
|
||||||
|
leftIcon="checkmark-circle-outline"
|
||||||
|
loading={createExpense.isPending}
|
||||||
|
onPress={() => void handleSave()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel"
|
||||||
|
leftIcon="close-circle-outline"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
saveActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
noticeText: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function mergeNotes(prefix: string, existing: string) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||||
|
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||||
|
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
type HubItem = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: HubItem[] = [
|
||||||
|
{
|
||||||
|
title: "Expenses",
|
||||||
|
subtitle: "Track costs and attach receipts",
|
||||||
|
href: "/(app)/more/expenses",
|
||||||
|
icon: "receipt-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
subtitle: "Revenue, hours, and tax summaries",
|
||||||
|
href: "/(app)/more/reports",
|
||||||
|
icon: "bar-chart-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Recurring invoices",
|
||||||
|
subtitle: "Scheduled billing templates",
|
||||||
|
href: "/(app)/more/recurring",
|
||||||
|
icon: "repeat-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Time entries",
|
||||||
|
subtitle: "Full history with edit and delete",
|
||||||
|
href: "/(app)/more/time-entries",
|
||||||
|
icon: "time-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
subtitle: "Account, security, and app preferences",
|
||||||
|
href: "/(app)/more/settings",
|
||||||
|
icon: "settings-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MoreScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView header={<PageHeader title="More" subtitle="Additional tools" />}>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{ITEMS.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.href}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
onPress={() => router.push(item.href as never)}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={item.icon} size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.title}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function RecurringScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const query = api.recurringInvoices.getAll.useQuery();
|
||||||
|
|
||||||
|
const pause = api.recurringInvoices.pause.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const resume = api.recurringInvoices.resume.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading recurring invoices…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = query.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Recurring"
|
||||||
|
subtitle={`${items.length} schedule${items.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() => query.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No recurring invoices yet. Create them on the web dashboard for now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "generate",
|
||||||
|
label: "Run",
|
||||||
|
icon: "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => generateNow.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "toggle",
|
||||||
|
label: item.status === "active" ? "Pause" : "Resume",
|
||||||
|
icon: item.status === "active" ? "pause-outline" : "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () =>
|
||||||
|
item.status === "active"
|
||||||
|
? pause.mutate({ id: item.id })
|
||||||
|
: resume.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{item.client?.name ?? "Client"} · {item.schedule} · {item.status}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
Next due {formatDate(item.nextDueAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(
|
||||||
|
item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
|
||||||
|
item.currency,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { StatCard } from "@/components/StatCard";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function ReportsScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const statsQuery = api.dashboard.getStats.useQuery();
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
const summaryQuery = api.timeEntries.getSummary.useQuery();
|
||||||
|
|
||||||
|
if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading reports…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Reports" subtitle="Business performance snapshot" />
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(statsQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsQuery.data!;
|
||||||
|
const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
|
||||||
|
const summary = summaryQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() =>
|
||||||
|
Promise.all([
|
||||||
|
statsQuery.refetch(),
|
||||||
|
expensesQuery.refetch(),
|
||||||
|
summaryQuery.refetch(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
<StatCard label="Expenses" value={formatCurrency(expenseTotal)} />
|
||||||
|
<StatCard
|
||||||
|
label="Billable hours"
|
||||||
|
value={summary ? summary.totalHours.toFixed(1) : "0"}
|
||||||
|
hint={summary ? `${summary.count} entries` : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Invoice status">
|
||||||
|
{(stats.statusChartData ?? []).map((item) => (
|
||||||
|
<View key={item.status} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{item.count} · {formatCurrency(item.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Revenue trend (6 mo)">
|
||||||
|
{stats.revenueChartData.map((point) => (
|
||||||
|
<View key={point.month} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{point.monthLabel}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatCurrency(point.revenue)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { InstanceUrlField } from "@/components/InstanceUrlField";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PinPrompt } from "@/components/PinPrompt";
|
||||||
|
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppLock } from "@/contexts/AppLockContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
|
import {
|
||||||
|
confirmRemoveAccount,
|
||||||
|
finishAccountRemoval,
|
||||||
|
} from "@/lib/account-actions";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||||
|
{ value: "system", label: "System" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
activeAccount,
|
||||||
|
activeAccountId,
|
||||||
|
apiUrl,
|
||||||
|
switchAccount,
|
||||||
|
removeAccount,
|
||||||
|
refreshAccounts,
|
||||||
|
clearActiveAccount,
|
||||||
|
} = useAccounts();
|
||||||
|
const { colors, colorMode, setColorMode } = useAppTheme();
|
||||||
|
const switchProps = {
|
||||||
|
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||||
|
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
|
||||||
|
ios_backgroundColor: colors.switchIosBackground,
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
enabled: lockEnabled,
|
||||||
|
biometricEnabled,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
enableLock,
|
||||||
|
disableLock,
|
||||||
|
changePin,
|
||||||
|
setUseBiometric,
|
||||||
|
lock,
|
||||||
|
} = useAppLock();
|
||||||
|
const profileQuery = api.settings.getProfile.useQuery();
|
||||||
|
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
|
||||||
|
|
||||||
|
const [pinPrompt, setPinPrompt] = useState<
|
||||||
|
| { mode: "create" }
|
||||||
|
| { mode: "confirm-disable" }
|
||||||
|
| { mode: "change-current" }
|
||||||
|
| { mode: "change-next" }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
const [pendingPin, setPendingPin] = useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
|
||||||
|
|
||||||
|
async function handleRefreshAccounts() {
|
||||||
|
setRefreshingAccounts(true);
|
||||||
|
try {
|
||||||
|
await refreshAccounts();
|
||||||
|
await profileQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
setRefreshingAccounts(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAccount(accountId: string, label: string) {
|
||||||
|
confirmRemoveAccount(
|
||||||
|
label,
|
||||||
|
() => removeAccount(accountId),
|
||||||
|
async (result) => {
|
||||||
|
await finishAccountRemoval({
|
||||||
|
result,
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSignOut() {
|
||||||
|
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sign out",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => void handleSignOut(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAccount() {
|
||||||
|
if (!activeAccountId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAccountMutation.mutateAsync({
|
||||||
|
confirmText: "DELETE MY ACCOUNT",
|
||||||
|
});
|
||||||
|
const result = await removeAccount(activeAccountId);
|
||||||
|
await finishAccountRemoval({
|
||||||
|
result,
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
|
if (result.remainingCount > 0) {
|
||||||
|
router.replace("/(auth)/select-account");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not delete account",
|
||||||
|
error instanceof Error ? error.message : "Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteAccount() {
|
||||||
|
Alert.alert(
|
||||||
|
"Permanently delete account?",
|
||||||
|
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete Account",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => void handleDeleteAccount(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmInstanceChange() {
|
||||||
|
Alert.alert(
|
||||||
|
"Server updated",
|
||||||
|
"You may need to sign in again if you switched to a different instance.",
|
||||||
|
[{ text: "OK" }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLockToggle(next: boolean) {
|
||||||
|
if (next) {
|
||||||
|
setPinPrompt({ mode: "create" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPinPrompt({ mode: "confirm-disable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangePin() {
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt({ mode: "change-current" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBiometricToggle(next: boolean) {
|
||||||
|
void setUseBiometric(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePinPromptSubmit(pin: string) {
|
||||||
|
if (pinPrompt?.mode === "create") {
|
||||||
|
try {
|
||||||
|
await enableLock(pin);
|
||||||
|
setPinPrompt(null);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not enable lock",
|
||||||
|
err instanceof Error ? err.message : "Try again",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "confirm-disable") {
|
||||||
|
const success = await disableLock(pin);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert("Incorrect PIN", "Could not disable app lock.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPinPrompt(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "change-current") {
|
||||||
|
setPendingPin(pin);
|
||||||
|
setPinPrompt({ mode: "change-next" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "change-next") {
|
||||||
|
const success = await changePin(pendingPin, pin);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not change PIN",
|
||||||
|
"Check your current PIN and try again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt(null);
|
||||||
|
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading profile…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = profileQuery.data;
|
||||||
|
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<PinPrompt
|
||||||
|
visible={pinPrompt !== null}
|
||||||
|
title={
|
||||||
|
pinPrompt?.mode === "create"
|
||||||
|
? "Create PIN"
|
||||||
|
: pinPrompt?.mode === "confirm-disable"
|
||||||
|
? "Disable app lock"
|
||||||
|
: pinPrompt?.mode === "change-current"
|
||||||
|
? "Current PIN"
|
||||||
|
: "New PIN"
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
|
? "Choose a 4–6 digit PIN."
|
||||||
|
: pinPrompt?.mode === "confirm-disable"
|
||||||
|
? "Enter your PIN to turn off app lock."
|
||||||
|
: "Enter your current PIN."
|
||||||
|
}
|
||||||
|
confirmLabel={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
|
? "Save"
|
||||||
|
: "Continue"
|
||||||
|
}
|
||||||
|
requireConfirmation={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
|
}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt(null);
|
||||||
|
}}
|
||||||
|
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
|
||||||
|
/>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
subtitle="Account and app preferences"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card title="Account">
|
||||||
|
<Text style={[styles.name, { color: colors.foreground }]}>
|
||||||
|
{profile?.name ?? session?.user.name ?? "User"}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.email, { color: colors.mutedForeground }]}>
|
||||||
|
{profile?.email ?? session?.user.email}
|
||||||
|
</Text>
|
||||||
|
{profile?.role ? (
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Role: {profile.role}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Accounts">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isActive = account.id === activeAccountId;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={account.id}
|
||||||
|
style={[
|
||||||
|
styles.accountRow,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: isActive ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void switchAccount(account.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.accountMain,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.accountMeta}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountName,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{account.name || account.email}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountSub,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{account.email}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.accountSub,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isActive ? (
|
||||||
|
<Text
|
||||||
|
style={[styles.activeBadge, { color: colors.primary }]}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Remove ${account.name || account.email}`}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() =>
|
||||||
|
handleRemoveAccount(
|
||||||
|
account.id,
|
||||||
|
account.name || account.email,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.removeButton,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.destructive}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={refreshingAccounts}
|
||||||
|
onPress={() => void handleRefreshAccounts()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Add another account"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() =>
|
||||||
|
void startAdditionalAccountSignIn(clearActiveAccount)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Tap an account to switch. Refresh updates names from saved sign-in
|
||||||
|
data.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<Card title="Shortcuts & Siri">
|
||||||
|
<ShortcutsSetupCard />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card title="Security">
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingCopy}>
|
||||||
|
<Text
|
||||||
|
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
App lock
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Require a PIN when reopening the app
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={lockEnabled}
|
||||||
|
onValueChange={handleLockToggle}
|
||||||
|
{...switchProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{lockEnabled && biometricAvailable ? (
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingCopy}>
|
||||||
|
<Text
|
||||||
|
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
{biometricLabel}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.meta, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
Unlock with {biometricLabel.toLowerCase()} when available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={biometricEnabled}
|
||||||
|
onValueChange={handleBiometricToggle}
|
||||||
|
{...switchProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lockEnabled ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
title="Change PIN"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleChangePin}
|
||||||
|
/>
|
||||||
|
<Button title="Lock now" variant="secondary" onPress={lock} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Appearance">
|
||||||
|
<View style={styles.themeRow}>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const selected = colorMode === option.value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void setColorMode(option.value)}
|
||||||
|
style={[
|
||||||
|
styles.themeChip,
|
||||||
|
{
|
||||||
|
borderColor: selected ? colors.primary : colors.border,
|
||||||
|
backgroundColor: selected
|
||||||
|
? colors.muted
|
||||||
|
: "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.themeChipLabel,
|
||||||
|
{
|
||||||
|
color: selected
|
||||||
|
? colors.foreground
|
||||||
|
: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="App">
|
||||||
|
<View style={styles.appRow}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Version
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||||
|
{appVersion}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.appRow}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Platform
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||||
|
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Delete account">
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Permanently delete this account and all of its data from the
|
||||||
|
server. This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
deleteAccountMutation.isPending
|
||||||
|
? "Deleting account…"
|
||||||
|
: "Delete Account"
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteAccountMutation.isPending}
|
||||||
|
disabled={deleteAccountMutation.isPending}
|
||||||
|
onPress={confirmDeleteAccount}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded: showAdvanced }}
|
||||||
|
onPress={() => setShowAdvanced((open) => !open)}
|
||||||
|
style={styles.advancedToggle}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{showAdvanced ? (
|
||||||
|
<Card title="Server instance">
|
||||||
|
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.currentServer,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title="Sign Out"
|
||||||
|
variant="danger"
|
||||||
|
onPress={confirmSignOut}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
name: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
currentServer: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
},
|
||||||
|
advancedToggle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
advancedLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
appRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
appValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
accountRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingLeft: spacing.md,
|
||||||
|
paddingRight: spacing.sm,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
accountMain: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
accountMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
accountSub: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
activeBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
themeRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
themeChip: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
themeChipLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
lineHeight: 18,
|
||||||
|
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
settingCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatRunningTimerLabel } from "@/lib/time-clock";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||||
|
|
||||||
|
function groupByDate(entries: TimeEntry[]) {
|
||||||
|
const groups = new Map<string, typeof entries>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const d = new Date(entry.startedAt);
|
||||||
|
const key = d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeEntriesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||||
|
|
||||||
|
const completed = useMemo(
|
||||||
|
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||||
|
[entriesQuery.data],
|
||||||
|
);
|
||||||
|
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
||||||
|
|
||||||
|
if (entriesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading time entries…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entriesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Time entries" subtitle="Completed work history" />
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={() => entriesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No completed entries yet. Start the timer from the Timer tab.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
grouped.map(([label, entries]) => (
|
||||||
|
<Card key={label} title={label}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={entry.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => setEditEntryId(entry.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatRunningTimerLabel(entry.description)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{entry.invoice
|
||||||
|
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
|
: " · not billed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{entry.hours ?? "—"}h
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
|
||||||
|
<TimeEntryEditSheet
|
||||||
|
entryId={editEntryId}
|
||||||
|
visible={editEntryId != null}
|
||||||
|
onClose={() => setEditEntryId(null)}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation();
|
||||||
|
const createClient = api.clients.create.useMutation();
|
||||||
|
const complete = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
await utils.settings.getProfile.invalidate();
|
||||||
|
router.replace("/(app)");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (businessName.trim()) {
|
||||||
|
await createBusiness.mutateAsync({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clientName.trim()) {
|
||||||
|
await createClient.mutateAsync({
|
||||||
|
name: clientName.trim(),
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await complete.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Welcome to beenvoice",
|
||||||
|
body: "Set up your workspace in a minute — business profile and first client.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Your business",
|
||||||
|
body: "This appears on invoices you send to clients.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "First client",
|
||||||
|
body: "Add someone you bill. You can skip and add clients later.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = steps[step]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
|
||||||
|
Step {step + 1} of {steps.length}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
|
||||||
|
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<Input
|
||||||
|
label="Business name"
|
||||||
|
value={businessName}
|
||||||
|
onChangeText={setBusinessName}
|
||||||
|
placeholder="Your studio or company"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<Input
|
||||||
|
label="Client name"
|
||||||
|
value={clientName}
|
||||||
|
onChangeText={setClientName}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{step > 0 ? (
|
||||||
|
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
|
||||||
|
) : null}
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
|
||||||
|
loading={complete.isPending}
|
||||||
|
onPress={() => void finish()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{statusQuery.data && !statusQuery.data.completed ? (
|
||||||
|
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
bodyText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
|
||||||
|
|
||||||
|
export default function TimerScreen() {
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
clientId?: string | string[];
|
||||||
|
invoiceId?: string | string[];
|
||||||
|
}>();
|
||||||
|
const clientId = Array.isArray(params.clientId) ? params.clientId[0] : params.clientId;
|
||||||
|
const invoiceId = Array.isArray(params.invoiceId) ? params.invoiceId[0] : params.invoiceId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TimeClockPanel
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Time clock"
|
||||||
|
subtitle="Track billable hours and link them to invoices"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultClientId={clientId ?? ""}
|
||||||
|
defaultInvoiceId={invoiceId ?? ""}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { requestPasswordReset } from "@/lib/auth-api";
|
||||||
|
import { isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||||
|
|
||||||
|
export default function ForgotPasswordScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverReady, setServerReady] = useState(true);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
const emailValidationError = !email.trim()
|
||||||
|
? "Email is required"
|
||||||
|
: isValidEmail(email)
|
||||||
|
? undefined
|
||||||
|
: "Enter a valid email";
|
||||||
|
const canSubmit = isValidEmail(email) && serverReady;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
markSubmitted();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestPasswordReset(email.trim());
|
||||||
|
setMessage(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Request failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={[styles.back, { color: colors.mutedForeground }]}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<AuthServerPicker onReadyChange={setServerReady} />
|
||||||
|
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Logo size="md" />
|
||||||
|
<HeadingText style={styles.title}>Reset password</HeadingText>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Enter your email and we'll send reset instructions if an account exists.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
onBlur={() => touch("email")}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
error={visible("email") ? emailValidationError : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
{message ? (
|
||||||
|
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Send reset link"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Have a reset token?"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => router.push("/(auth)/reset-password")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: { gap: spacing.lg },
|
||||||
|
header: { gap: spacing.sm },
|
||||||
|
title: { fontSize: 28 },
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
form: { gap: spacing.md },
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
|
||||||
|
export default function AuthIndex() {
|
||||||
|
const { accounts, activeAccountId } = useAccounts();
|
||||||
|
|
||||||
|
if (!activeAccountId && accounts.length > 0) {
|
||||||
|
return <Redirect href="/(auth)/select-account" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Link } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
|
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient } from "@/contexts/AuthContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { registerAccount } from "@/lib/auth-api";
|
||||||
|
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||||
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPassword,
|
||||||
|
useFieldVisibility,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverReady, setServerReady] = useState(true);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
const firstNameError = isRequiredString(firstName) ? undefined : "First name is required";
|
||||||
|
const lastNameError = isRequiredString(lastName) ? undefined : "Last name is required";
|
||||||
|
const emailValidationError = isValidEmail(email)
|
||||||
|
? undefined
|
||||||
|
: email.trim()
|
||||||
|
? "Enter a valid email"
|
||||||
|
: "Email is required";
|
||||||
|
const passwordValidationError = isValidPassword(password)
|
||||||
|
? undefined
|
||||||
|
: password
|
||||||
|
? "Password must be at least 8 characters"
|
||||||
|
: "Password is required";
|
||||||
|
const canRegister =
|
||||||
|
isRequiredString(firstName) &&
|
||||||
|
isRequiredString(lastName) &&
|
||||||
|
isValidEmail(email) &&
|
||||||
|
isValidPassword(password) &&
|
||||||
|
serverReady;
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
markSubmitted();
|
||||||
|
if (!canRegister) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registerAccount({
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error: signInError } = await authClient.signIn.email({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
setError(signInError.message || "Account created but sign-in failed. Try signing in.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
const user = session.data?.user;
|
||||||
|
if (user) {
|
||||||
|
const completed = await completeSignInAfterAuth(authClient, {
|
||||||
|
apiUrl,
|
||||||
|
activeAccountId,
|
||||||
|
registerAccount: saveAccount,
|
||||||
|
});
|
||||||
|
if (!completed) {
|
||||||
|
setError("Account created but session setup failed. Try signing in.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Account created. Sign in with your email and password.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreenLayout>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Create your account"
|
||||||
|
description="Get started with your workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.half}>
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
leftIcon="person-outline"
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
onBlur={() => touch("firstName")}
|
||||||
|
autoComplete="given-name"
|
||||||
|
placeholder="John"
|
||||||
|
required
|
||||||
|
error={visible("firstName") ? firstNameError : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.half}>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
leftIcon="person-outline"
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
onBlur={() => touch("lastName")}
|
||||||
|
autoComplete="family-name"
|
||||||
|
placeholder="Doe"
|
||||||
|
required
|
||||||
|
error={visible("lastName") ? lastNameError : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
leftIcon="mail-outline"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
onBlur={() => touch("email")}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
error={visible("email") ? emailValidationError : undefined}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
leftIcon="lock-closed-outline"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
onBlur={() => touch("password")}
|
||||||
|
placeholder="••••••••"
|
||||||
|
hint="At least 8 characters"
|
||||||
|
required
|
||||||
|
error={visible("password") ? passwordValidationError : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={loading ? "Creating account…" : "Create account"}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canRegister}
|
||||||
|
showArrow={!loading}
|
||||||
|
onPress={handleRegister}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<LegalAgreementNotice action="creating an account" />
|
||||||
|
</AuthCard>
|
||||||
|
</AuthScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
form: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
half: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
|
import { HeadingText } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { resetPassword } from "@/lib/auth-api";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
isValidPassword,
|
||||||
|
useFieldVisibility,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
|
|
||||||
|
export default function ResetPasswordScreen() {
|
||||||
|
const styles = useThemedStyles(createResetPasswordStyles);
|
||||||
|
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverReady, setServerReady] = useState(true);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof tokenParam === "string" && tokenParam.length > 0) {
|
||||||
|
setToken(tokenParam);
|
||||||
|
}
|
||||||
|
}, [tokenParam]);
|
||||||
|
|
||||||
|
const tokenError = isRequiredString(token) ? undefined : "Reset token is required";
|
||||||
|
const passwordError = isValidPassword(password)
|
||||||
|
? undefined
|
||||||
|
: password
|
||||||
|
? "Password must be at least 8 characters"
|
||||||
|
: "Password is required";
|
||||||
|
const confirmError =
|
||||||
|
confirmPassword && password !== confirmPassword ? "Passwords do not match" : undefined;
|
||||||
|
const canSubmit =
|
||||||
|
serverReady &&
|
||||||
|
isRequiredString(token) &&
|
||||||
|
isValidPassword(password) &&
|
||||||
|
password === confirmPassword &&
|
||||||
|
confirmPassword.length > 0;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
markSubmitted();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resetPassword(token.trim(), password);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Reset failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={styles.back}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<AuthServerPicker onReadyChange={setServerReady} />
|
||||||
|
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<HeadingText style={styles.title}>Set new password</HeadingText>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Paste the reset token from your email, or open the link on this device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<View style={styles.successBox}>
|
||||||
|
<Text style={styles.successTitle}>Password updated</Text>
|
||||||
|
<Text style={styles.successText}>
|
||||||
|
You can now sign in with your new password.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Go to sign in"
|
||||||
|
onPress={() => router.replace("/(auth)/sign-in")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Reset token"
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={token}
|
||||||
|
onChangeText={setToken}
|
||||||
|
onBlur={() => touch("token")}
|
||||||
|
placeholder="Paste token from email"
|
||||||
|
required
|
||||||
|
error={visible("token") ? tokenError : undefined}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New password"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
onBlur={() => touch("password")}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
required
|
||||||
|
error={visible("password") ? passwordError : undefined}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm password"
|
||||||
|
secureTextEntry
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
onBlur={() => touch("confirmPassword")}
|
||||||
|
placeholder="Repeat password"
|
||||||
|
required
|
||||||
|
error={visible("confirmPassword") ? confirmError : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Update password"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createResetPasswordStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: { gap: spacing.lg },
|
||||||
|
header: { gap: spacing.sm },
|
||||||
|
title: { fontSize: 28 },
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
form: { gap: spacing.md },
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
successBox: {
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
successTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
successText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
|
import { formatServerHost } from "@/lib/server-mode";
|
||||||
|
|
||||||
|
function initials(name: string, email: string) {
|
||||||
|
const source = name.trim() || email.trim();
|
||||||
|
const parts = source.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return (source[0] ?? "?").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectAccountScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { accounts, activeAccountId, switchAccount, clearActiveAccount } = useAccounts();
|
||||||
|
const [selectingId, setSelectingId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
async function handleSelect(accountId: string) {
|
||||||
|
if (selectingId) return;
|
||||||
|
setSelectingId(accountId);
|
||||||
|
try {
|
||||||
|
await switchAccount(accountId);
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
} finally {
|
||||||
|
setSelectingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAccount() {
|
||||||
|
if (adding) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await startAdditionalAccountSignIn(clearActiveAccount);
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeAccountId || accounts.length === 0) {
|
||||||
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreenLayout>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Choose account"
|
||||||
|
description="Select the workspace account to use on this device"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.list}>
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isSelecting = selectingId === account.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={Boolean(selectingId)}
|
||||||
|
key={account.id}
|
||||||
|
onPress={() => void handleSelect(account.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.accountRow,
|
||||||
|
{ backgroundColor: colors.muted, borderColor: colors.border },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
||||||
|
{initials(account.name, account.email)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.accountMeta}>
|
||||||
|
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||||
|
{account.name || account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{formatServerHost(account.instanceUrl)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelecting ? (
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={Boolean(selectingId)}
|
||||||
|
loading={adding}
|
||||||
|
onPress={() => void handleAddAccount()}
|
||||||
|
title="Sign in to another account"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
</AuthCard>
|
||||||
|
</AuthScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
accountRow: {
|
||||||
|
minHeight: 72,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
accountMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
accountSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { Link, router } from "expo-router";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||||
|
import { AuthNotice } from "@/components/auth/AuthNotice";
|
||||||
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
|
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient } from "@/contexts/AuthContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
||||||
|
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
||||||
|
import { prepareAuthScreenSession } from "@/lib/auth-session";
|
||||||
|
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||||
|
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||||
|
|
||||||
|
export default function SignInScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverReady, setServerReady] = useState(true);
|
||||||
|
const [authentikEnabled, setAuthentikEnabled] = useState(false);
|
||||||
|
const [signupsDisabled, setSignupsDisabled] = useState(false);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
|
||||||
|
}, [authClient, activeAccountId, clearActiveAccount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthentikEnabled(capabilities.authentik);
|
||||||
|
setSignupsDisabled(capabilities.signupsDisabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
const emailValidationError = !email.trim()
|
||||||
|
? "Email is required"
|
||||||
|
: isValidEmail(email)
|
||||||
|
? undefined
|
||||||
|
: "Enter a valid email";
|
||||||
|
const passwordValidationError = password.trim() ? undefined : "Password is required";
|
||||||
|
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
|
||||||
|
|
||||||
|
async function finishSignIn() {
|
||||||
|
const completed = await completeSignInAfterAuth(authClient, {
|
||||||
|
apiUrl,
|
||||||
|
activeAccountId,
|
||||||
|
registerAccount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!completed) {
|
||||||
|
setError("Signed in but session was not available. Try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
markSubmitted();
|
||||||
|
if (!canSignIn) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: signInError } = await authClient.signIn.email({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
setError(formatAuthErrorMessage(signInError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishSignIn();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthentikSignIn() {
|
||||||
|
if (!serverReady) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: oauthError } = await signInWithAuthentik(
|
||||||
|
authClient,
|
||||||
|
Linking.createURL("/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (oauthError) {
|
||||||
|
setError(formatAuthErrorMessage(oauthError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishSignIn();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreenLayout>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
|
||||||
|
|
||||||
|
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||||
|
|
||||||
|
{signupsDisabled ? (
|
||||||
|
<AuthNotice>New account registration is currently disabled.</AuthNotice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{authentikEnabled ? (
|
||||||
|
<View style={styles.ssoSection}>
|
||||||
|
<Button
|
||||||
|
title="Sign in with Authentik"
|
||||||
|
variant="secondary"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!serverReady}
|
||||||
|
onPress={() => void handleAuthentikSignIn()}
|
||||||
|
/>
|
||||||
|
<AuthDivider />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
leftIcon="mail-outline"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
onBlur={() => touch("email")}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
error={visible("email") ? emailValidationError : undefined}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
leftIcon="lock-closed-outline"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
onBlur={() => touch("password")}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
error={visible("password") ? passwordValidationError : undefined}
|
||||||
|
labelAccessory={
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
|
||||||
|
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||||
|
Forgot password?
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={loading ? "Signing in…" : "Sign in"}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canSignIn}
|
||||||
|
showArrow={!loading}
|
||||||
|
onPress={handleSignIn}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!signupsDisabled ? (
|
||||||
|
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LegalAgreementNotice action="signing in" />
|
||||||
|
</AuthCard>
|
||||||
|
</AuthScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
ssoSection: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
forgot: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// This file is web-only and used to configure the root HTML for every
|
||||||
|
// web page during static rendering.
|
||||||
|
// The contents of this function only run in Node.js environments and
|
||||||
|
// do not have access to the DOM or browser APIs.
|
||||||
|
export default function Root({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||||
|
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||||
|
*/}
|
||||||
|
<ScrollViewStyleReset />
|
||||||
|
|
||||||
|
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||||
|
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsiveBackground = `
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||||
|
|
||||||
|
<Link href="/" style={styles.link}>
|
||||||
|
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#2e78b7',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import {
|
||||||
|
Inter_400Regular,
|
||||||
|
Inter_500Medium,
|
||||||
|
Inter_600SemiBold,
|
||||||
|
Inter_700Bold,
|
||||||
|
} from "@expo-google-fonts/inter";
|
||||||
|
import {
|
||||||
|
PlayfairDisplay_600SemiBold,
|
||||||
|
PlayfairDisplay_700Bold,
|
||||||
|
} from "@expo-google-fonts/playfair-display";
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
|
||||||
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SessionSync } from "@/components/SessionSync";
|
||||||
|
import { ShortcutLinkCapture } from "@/components/ShortcutLinkCapture";
|
||||||
|
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { TRPCProvider } from "@/lib/trpc";
|
||||||
|
export { ErrorBoundary } from "expo-router";
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
function AppServices({ children }: { children: ReactNode }) {
|
||||||
|
const { apiUrl, authStoragePrefix, activeAccountId } = useAccounts();
|
||||||
|
const remountKey = `${activeAccountId ?? "guest"}:${apiUrl}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
|
||||||
|
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
|
||||||
|
<SessionSync />
|
||||||
|
<ShortcutLinkCapture />
|
||||||
|
{children}
|
||||||
|
</TRPCProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemedChrome({ children }: { children: ReactNode }) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "transparent" }}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={{ flex: 1, zIndex: 1 }}>
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [loaded, error] = useFonts({
|
||||||
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
|
Inter_400Regular,
|
||||||
|
Inter_500Medium,
|
||||||
|
Inter_600SemiBold,
|
||||||
|
Inter_700Bold,
|
||||||
|
PlayfairDisplay_600SemiBold,
|
||||||
|
PlayfairDisplay_700Bold,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) throw error;
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [loaded]);
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemedChrome>
|
||||||
|
<AccountsProvider>
|
||||||
|
<AppServices>
|
||||||
|
<RootNavigator />
|
||||||
|
</AppServices>
|
||||||
|
</AccountsProvider>
|
||||||
|
</ThemedChrome>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootNavigator() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const { activeAccountId } = useAccounts();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <LoadingScreen message="Checking session…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(session?.user && activeAccountId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Protected guard={!isAuthenticated}>
|
||||||
|
<Stack.Screen name="(auth)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={isAuthenticated}>
|
||||||
|
<Stack.Screen name="(app)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;">
|
||||||
|
<g id="grid-lines">
|
||||||
|
<path d="M0,0L0,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M200,0L200,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M400,0L400,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M600,0L600,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M800,0L800,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M1000,0L1000,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,0L1000,0" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,200L1000,200" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,400L1000,400" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,600L1000,600" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,800L1000,800" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,1000L1000,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(92, 0)">
|
||||||
|
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
||||||
|
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
||||||
|
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
||||||
|
<path
|
||||||
|
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"linear-gradient" : [
|
||||||
|
"display-p3:0.75855,0.75855,0.75855,1.00000",
|
||||||
|
"display-p3:0.47359,0.47359,0.47359,1.00000"
|
||||||
|
],
|
||||||
|
"orientation" : {
|
||||||
|
"start" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0
|
||||||
|
},
|
||||||
|
"stop" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : {
|
||||||
|
"linear-gradient" : [
|
||||||
|
"display-p3:0.36520,0.36520,0.36520,1.00000",
|
||||||
|
"extended-gray:0.00000,1.00000"
|
||||||
|
],
|
||||||
|
"orientation" : {
|
||||||
|
"start" : {
|
||||||
|
"x" : 0.4999999999999998,
|
||||||
|
"y" : 0
|
||||||
|
},
|
||||||
|
"stop" : {
|
||||||
|
"x" : 0.4999999999999998,
|
||||||
|
"y" : 0.5617755083064717
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "extended-gray:0.75000,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "extended-gray:0.50000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass" : true,
|
||||||
|
"image-name" : "beenvoice.svg",
|
||||||
|
"name" : "beenvoice",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.85,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"fill" : {
|
||||||
|
"linear-gradient" : [
|
||||||
|
"display-p3:0.59424,0.59424,0.59424,1.00000",
|
||||||
|
"display-p3:0.33555,0.33555,0.33555,1.00000"
|
||||||
|
],
|
||||||
|
"orientation" : {
|
||||||
|
"start" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0
|
||||||
|
},
|
||||||
|
"stop" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "5x5-solid-lines-grid(1).svg",
|
||||||
|
"name" : "5x5-solid-lines-grid(1)",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.25,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(92, 0)">
|
||||||
|
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
||||||
|
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
||||||
|
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
||||||
|
<path
|
||||||
|
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,374 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
|
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatServerHost } from "@/lib/server-mode";
|
||||||
|
|
||||||
|
function initials(name: string, email: string) {
|
||||||
|
const source = name.trim() || email.trim();
|
||||||
|
const parts = source.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return (source[0] ?? "?").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(name: string, email: string) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
|
||||||
|
return email.split("@")[0] ?? email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header control to switch signed-in accounts or add another. */
|
||||||
|
export function AccountSwitcher() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
activeAccount,
|
||||||
|
activeAccountId,
|
||||||
|
switchAccount,
|
||||||
|
removeAccount,
|
||||||
|
refreshAccounts,
|
||||||
|
clearActiveAccount,
|
||||||
|
} = useAccounts();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const label = displayName(
|
||||||
|
activeAccount?.name ?? session?.user.name ?? "",
|
||||||
|
activeAccount?.email ?? session?.user.email ?? "",
|
||||||
|
);
|
||||||
|
const avatar = initials(
|
||||||
|
activeAccount?.name ?? session?.user.name ?? "",
|
||||||
|
activeAccount?.email ?? session?.user.email ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleAddAccount() {
|
||||||
|
setOpen(false);
|
||||||
|
await startAdditionalAccountSignIn(clearActiveAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwitch(accountId: string) {
|
||||||
|
if (accountId === activeAccountId) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
await switchAccount(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await refreshAccounts();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenSettings() {
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/(app)/more/settings" as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(accountId: string, label: string) {
|
||||||
|
confirmRemoveAccount(
|
||||||
|
label,
|
||||||
|
() => removeAccount(accountId),
|
||||||
|
async (result) => {
|
||||||
|
if (result.remainingCount === 0) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
await finishAccountRemoval({
|
||||||
|
result,
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Switch account"
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
style={styles.hit}
|
||||||
|
>
|
||||||
|
<View style={[styles.row, { backgroundColor: colors.muted }]}>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
||||||
|
{avatar}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.name, { color: colors.foreground }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: colors.background }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||||
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
|
||||||
|
<View style={styles.sheetActions}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh accounts"
|
||||||
|
disabled={refreshing}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => void handleRefresh()}
|
||||||
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="refresh" size={20} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
||||||
|
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isActive = account.id === activeAccountId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={account.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void handleSwitch(account.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.accountRow,
|
||||||
|
{
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
backgroundColor: isActive ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
||||||
|
{initials(account.name, account.email)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.accountMeta}>
|
||||||
|
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||||
|
{account.name || account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{formatServerHost(account.instanceUrl)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.accountActions}>
|
||||||
|
{isActive ? (
|
||||||
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||||
|
) : null}
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Remove ${account.name || account.email}`}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() =>
|
||||||
|
handleRemove(account.id, account.name || account.email)
|
||||||
|
}
|
||||||
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void handleAddAccount()}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.addRow,
|
||||||
|
{ borderTopColor: colors.border },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
|
||||||
|
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleOpenSettings}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.settingsRow,
|
||||||
|
{ borderTopColor: colors.border },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="settings-outline" size={21} color={colors.mutedForeground} />
|
||||||
|
<Text style={[styles.settingsLabel, { color: colors.foreground }]}>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
hit: {
|
||||||
|
flexShrink: 1,
|
||||||
|
maxWidth: "58%",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingRight: 8,
|
||||||
|
minHeight: 32,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
flexShrink: 1,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.45)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderTopLeftRadius: radii.xl,
|
||||||
|
borderTopRightRadius: radii.xl,
|
||||||
|
maxHeight: "70%",
|
||||||
|
},
|
||||||
|
sheetHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
sheetTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
sheetActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 28,
|
||||||
|
minHeight: 28,
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
accountRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
accountMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
accountActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
accountSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
addRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
addLabel: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
settingsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
settingsLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.75,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
|
||||||
|
/** Auth screens — brand grid/blob behind content. */
|
||||||
|
export function AuthBackground({ style, children, ...props }: ViewProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, style]} {...props}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App tab/stack screens — brand grid/blob behind content (native tabs block the root layer). */
|
||||||
|
export function AppBackground({ style, children, ...props }: ViewProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, style]} {...props}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppLock } from "@/contexts/AppLockContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export function AppLockOverlay() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
isLocked,
|
||||||
|
biometricEnabled,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
unlockWithPin,
|
||||||
|
unlockWithBiometric,
|
||||||
|
} = useAppLock();
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const promptedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked) {
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
promptedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (promptedRef.current) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
promptedRef.current = true;
|
||||||
|
void unlockWithBiometric().then((success) => {
|
||||||
|
if (!success) return;
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [enabled, isLocked, biometricEnabled, biometricAvailable, unlockWithBiometric]);
|
||||||
|
|
||||||
|
if (!enabled || !isLocked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPin() {
|
||||||
|
const success = await unlockWithPin(pin);
|
||||||
|
if (success) {
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("Incorrect PIN");
|
||||||
|
setPin("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryBiometric() {
|
||||||
|
promptedRef.current = true;
|
||||||
|
const success = await unlockWithBiometric();
|
||||||
|
if (!success) {
|
||||||
|
setError(`Could not unlock with ${biometricLabel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="fade" transparent={false}>
|
||||||
|
<View style={[styles.screen, { backgroundColor: colors.background }]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Logo size="md" />
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>Locked</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Enter your PIN to continue
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={pin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
style={[
|
||||||
|
styles.pinInput,
|
||||||
|
{
|
||||||
|
color: colors.foreground,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
onSubmitEditing={() => void submitPin()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
|
||||||
|
|
||||||
|
{biometricAvailable ? (
|
||||||
|
<Button
|
||||||
|
title={`Unlock with ${biometricLabel}`}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => void tryBiometric()}
|
||||||
|
style={styles.biometricButton}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 320,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
pinInput: {
|
||||||
|
width: "100%",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 52,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
width: "100%",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
biometricButton: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
|
||||||
|
import {
|
||||||
|
formatServerHost,
|
||||||
|
isServerConfigValid,
|
||||||
|
resolveServerMode,
|
||||||
|
resolveServerUrl,
|
||||||
|
SERVER_MODE_OPTIONS,
|
||||||
|
type ServerMode,
|
||||||
|
} from "@/lib/server-mode";
|
||||||
|
|
||||||
|
type AuthServerPickerProps = {
|
||||||
|
onReadyChange?: (ready: boolean) => void;
|
||||||
|
/** When true, picker sits inside the auth card with no outer margin. */
|
||||||
|
embedded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
|
||||||
|
if (mode === "official") return "Official";
|
||||||
|
const host = formatServerHost(selfHostedUrl);
|
||||||
|
return host || "Self-hosted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
|
||||||
|
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
|
||||||
|
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
|
||||||
|
);
|
||||||
|
const [urlError, setUrlError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ready = isServerConfigValid(mode, selfHostedUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onReadyChange?.(ready);
|
||||||
|
}, [ready, onReadyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextMode = resolveServerMode(apiUrl);
|
||||||
|
setMode(nextMode);
|
||||||
|
if (nextMode === "self-hosted") {
|
||||||
|
setSelfHostedUrl(apiUrl);
|
||||||
|
}
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
async function applyMode(nextMode: ServerMode) {
|
||||||
|
setMode(nextMode);
|
||||||
|
setUrlError(null);
|
||||||
|
|
||||||
|
if (nextMode === "official") {
|
||||||
|
try {
|
||||||
|
await setInstanceUrl(DEFAULT_API_URL);
|
||||||
|
setExpanded(false);
|
||||||
|
} catch (err) {
|
||||||
|
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpanded(true);
|
||||||
|
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
||||||
|
if (!resolved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setInstanceUrl(resolved);
|
||||||
|
} catch (err) {
|
||||||
|
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSelfHostedUrl() {
|
||||||
|
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
||||||
|
if (!resolved) {
|
||||||
|
setUrlError(invalidServerUrlMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await setInstanceUrl(resolved);
|
||||||
|
setSelfHostedUrl(saved);
|
||||||
|
setUrlError(null);
|
||||||
|
setExpanded(false);
|
||||||
|
} catch (err) {
|
||||||
|
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded }}
|
||||||
|
onPress={() => setExpanded((open) => !open)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
|
||||||
|
Server ·{" "}
|
||||||
|
<Text style={[styles.summary, { color: colors.foreground }]}>
|
||||||
|
{modeSummary(mode, selfHostedUrl)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.panel,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{SERVER_MODE_OPTIONS.map((option) => {
|
||||||
|
const selected = option.value === mode;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
onPress={() => void applyMode(option.value)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.option,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: selected ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{selected ? (
|
||||||
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{mode === "self-hosted" ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Server URL"
|
||||||
|
value={selfHostedUrl}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setSelfHostedUrl(value);
|
||||||
|
setUrlError(null);
|
||||||
|
}}
|
||||||
|
onBlur={() => void commitSelfHostedUrl()}
|
||||||
|
onSubmitEditing={() => void commitSelfHostedUrl()}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||||
|
required
|
||||||
|
error={urlError ?? undefined}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||||
|
Use your Mac's LAN IP on a physical device.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
wrapperEmbedded: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
minHeight: 44,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { StyleSheet, useWindowDimensions, View, type ViewProps } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import Svg, { Circle, Defs, Line, RadialGradient, Stop } from "react-native-svg";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { blobAnimation, blobDiameter } from "@/lib/beenvoice-theme";
|
||||||
|
import { getBackgroundTokens } from "@/lib/theme-palette";
|
||||||
|
|
||||||
|
export function BrandBackground({ style, ...props }: ViewProps) {
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const tokens = useMemo(() => getBackgroundTokens(colorScheme), [colorScheme]);
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
const gridLines = useMemo(() => {
|
||||||
|
const vertical: Array<{ key: string; x: number }> = [];
|
||||||
|
const horizontal: Array<{ key: string; y: number }> = [];
|
||||||
|
for (let x = 0; x <= width; x += tokens.gridSize) {
|
||||||
|
vertical.push({ key: `v-${x}`, x });
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= height; y += tokens.gridSize) {
|
||||||
|
horizontal.push({ key: `h-${y}`, y });
|
||||||
|
}
|
||||||
|
return { vertical, horizontal };
|
||||||
|
}, [width, height, tokens.gridSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.root, { backgroundColor: tokens.background }, style]}
|
||||||
|
pointerEvents="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
||||||
|
{gridLines.vertical.map((line) => (
|
||||||
|
<Line
|
||||||
|
key={line.key}
|
||||||
|
x1={line.x}
|
||||||
|
y1={0}
|
||||||
|
x2={line.x}
|
||||||
|
y2={height}
|
||||||
|
stroke={tokens.gridLine}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{gridLines.horizontal.map((line) => (
|
||||||
|
<Line
|
||||||
|
key={line.key}
|
||||||
|
x1={0}
|
||||||
|
y1={line.y}
|
||||||
|
x2={width}
|
||||||
|
y2={line.y}
|
||||||
|
stroke={tokens.gridLine}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
|
||||||
|
<AmbientBlob cx={cx} cy={cy} blobCore={tokens.blobCore} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmbientBlob({ cx, cy, blobCore }: { cx: number; cy: number; blobCore: string }) {
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const r = blobDiameter / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withRepeat(
|
||||||
|
withTiming(1, {
|
||||||
|
duration: blobAnimation.durationMs,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const k = blobAnimation.keyframes;
|
||||||
|
const t = progress.value;
|
||||||
|
const seg = t < 0.33 ? 0 : t < 0.66 ? 1 : 2;
|
||||||
|
const local = seg === 0 ? t / 0.33 : seg === 1 ? (t - 0.33) / 0.33 : (t - 0.66) / 0.34;
|
||||||
|
const from = k[seg]!;
|
||||||
|
const to = k[seg + 1] ?? k[0]!;
|
||||||
|
const lerp = (a: number, b: number) => a + (b - a) * local;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{ translateX: lerp(from.translateX, to.translateX) },
|
||||||
|
{ translateY: lerp(from.translateY, to.translateY) },
|
||||||
|
{ scale: lerp(from.scale, to.scale) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.blobLayer, animatedStyle]} pointerEvents="none">
|
||||||
|
<Svg width={blobDiameter * 1.6} height={blobDiameter * 1.6}>
|
||||||
|
<Defs>
|
||||||
|
<RadialGradient id="blob-a" cx="50%" cy="50%" r="50%">
|
||||||
|
<Stop offset="0%" stopColor={blobCore} stopOpacity={0.9} />
|
||||||
|
<Stop offset="38%" stopColor={blobCore} stopOpacity={0.35} />
|
||||||
|
<Stop offset="62%" stopColor={blobCore} stopOpacity={0.1} />
|
||||||
|
<Stop offset="100%" stopColor={blobCore} stopOpacity={0} />
|
||||||
|
</RadialGradient>
|
||||||
|
</Defs>
|
||||||
|
<Circle cx={blobDiameter * 0.8} cy={blobDiameter * 0.8} r={r} fill="url(#blob-a)" />
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
},
|
||||||
|
blobLayer: {
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
width: blobDiameter * 1.6,
|
||||||
|
height: blobDiameter * 1.6,
|
||||||
|
marginLeft: -(blobDiameter * 0.8),
|
||||||
|
marginTop: -(blobDiameter * 0.8),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
|
||||||
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Green dot + elapsed time when a timer is running; tappable to open the clock. */
|
||||||
|
export function ClockedInIndicator() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
const running = runningQuery.data;
|
||||||
|
const elapsed = useRunningElapsed(running?.startedAt);
|
||||||
|
|
||||||
|
if (!running) return null;
|
||||||
|
|
||||||
|
const label = formatElapsedHoursMinutes(elapsed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Clocked in, ${label}`}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => router.push("/(app)/timer")}
|
||||||
|
style={styles.hit}
|
||||||
|
>
|
||||||
|
<View style={[styles.row, { backgroundColor: colors.successBg }]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: colors.success }]} />
|
||||||
|
<Text style={[styles.time, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
hit: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
minHeight: 28,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
|
||||||
|
import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
|
||||||
|
|
||||||
|
type CollapsibleServerFieldProps = {
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatServerLabel(url: string) {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleServerFieldProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasConfiguredInstanceUrl().then((configured) => {
|
||||||
|
if (!configured) setExpanded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(apiUrl);
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === apiUrl) {
|
||||||
|
setError(null);
|
||||||
|
setExpanded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await setInstanceUrl(trimmed);
|
||||||
|
setValue(saved);
|
||||||
|
setError(null);
|
||||||
|
setExpanded(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save server URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.wrapper,
|
||||||
|
{ paddingBottom: Math.max(insets.bottom, spacing.sm) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded }}
|
||||||
|
onPress={() => setExpanded((open) => !open)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
|
||||||
|
Server ·{" "}
|
||||||
|
<Text style={[styles.host, { color: colors.foreground }]}>
|
||||||
|
{formatServerLabel(apiUrl)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-down" : "chevron-up"}
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.panel,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Server instance"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
onBlur={commit}
|
||||||
|
onSubmitEditing={commit}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||||
|
error={error ?? undefined}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||||
|
Use your Mac's LAN IP on a physical device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
flexDirection: "column-reverse",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ExternalLink } from './ExternalLink';
|
||||||
|
import { MonoText } from './StyledText';
|
||||||
|
import { Text, View } from './Themed';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
export default function EditScreenInfo({ path }: { path: string }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.getStartedContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Open up the code for this screen:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
||||||
|
darkColor="rgba(255,255,255,0.05)"
|
||||||
|
lightColor="rgba(0,0,0,0.05)">
|
||||||
|
<MonoText>{path}</MonoText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Change any of the text, save the file, and your app will automatically update.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpContainer}>
|
||||||
|
<ExternalLink
|
||||||
|
style={styles.helpLink}
|
||||||
|
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
||||||
|
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
||||||
|
Tap here if your app doesn't automatically update after making changes
|
||||||
|
</Text>
|
||||||
|
</ExternalLink>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
getStartedContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 50,
|
||||||
|
},
|
||||||
|
homeScreenFilename: {
|
||||||
|
marginVertical: 7,
|
||||||
|
},
|
||||||
|
codeHighlightContainer: {
|
||||||
|
borderRadius: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
getStartedText: {
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
helpContainer: {
|
||||||
|
marginTop: 15,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
helpLink: {
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
helpLinkText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Link, type Href } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> & { href: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
href={props.href as Href}
|
||||||
|
onPress={(e) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
e.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
WebBrowser.openBrowserAsync(props.href as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type FilterChipProps = {
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: Boolean(active) }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
|
||||||
|
active && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.chipInner}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.label,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
active && { color: colors.primaryForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
chip: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
chipInner: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Pressable, StyleSheet, Text } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, radii } from "@/constants/theme";
|
||||||
|
import { useFloatingActionBottom } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
type FloatingActionButtonProps = {
|
||||||
|
onPress: () => void;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FloatingActionButton({
|
||||||
|
onPress,
|
||||||
|
accessibilityLabel = "Create",
|
||||||
|
}: FloatingActionButtonProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const bottom = useFloatingActionBottom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.fab,
|
||||||
|
{
|
||||||
|
bottom,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
shadowColor: colors.foreground,
|
||||||
|
},
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.icon, { color: colors.primaryForeground }]}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fab: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 20,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
transform: [{ scale: 0.96 }],
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 34,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { blurIntensity, radius, shadowMd, shadowSm } from "@/lib/beenvoice-theme";
|
||||||
|
|
||||||
|
type GlassSurfaceProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
radius?: number;
|
||||||
|
variant?: "card" | "stat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GlassSurface({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
radius: cornerRadius = radius.lg,
|
||||||
|
variant = "card",
|
||||||
|
}: GlassSurfaceProps) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const flat = StyleSheet.flatten(style);
|
||||||
|
const isStat = variant === "stat";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.shell,
|
||||||
|
isStat ? styles.statShell : null,
|
||||||
|
{ borderRadius: cornerRadius, borderColor: colors.borderGlass },
|
||||||
|
isStat ? shadowMd : shadowSm,
|
||||||
|
flat,
|
||||||
|
Platform.OS === "android" ? { backgroundColor: colors.cardGlass } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={blurIntensity.card}
|
||||||
|
tint={isDark ? "dark" : "light"}
|
||||||
|
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassChrome({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
radius: cornerRadius = 0,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
radius?: number;
|
||||||
|
}) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.chromeShell,
|
||||||
|
{ borderRadius: cornerRadius, backgroundColor: colors.cardGlass },
|
||||||
|
StyleSheet.flatten(style),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={blurIntensity.chrome}
|
||||||
|
tint={isDark ? "dark" : "light"}
|
||||||
|
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{children ? <View style={styles.content}>{children}</View> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
shell: {
|
||||||
|
overflow: "hidden",
|
||||||
|
borderWidth: StyleSheet.hairlineWidth * 2,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
statShell: {
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
chromeShell: {
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
|
||||||
|
import { normalizeInstanceUrl } from "@/lib/instance-url";
|
||||||
|
|
||||||
|
type InstanceUrlFieldProps = {
|
||||||
|
onSaved?: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||||
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(apiUrl);
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === apiUrl) {
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeInstanceUrl(trimmed);
|
||||||
|
if (!normalized) {
|
||||||
|
setError(invalidServerUrlMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await setInstanceUrl(trimmed);
|
||||||
|
setValue(saved);
|
||||||
|
setError(null);
|
||||||
|
onSaved?.(saved);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save server URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Input
|
||||||
|
label="Server instance"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
onBlur={commit}
|
||||||
|
onSubmitEditing={commit}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||||
|
error={error ?? undefined}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||||
|
Point the app at your beenvoice server. Use your Mac's LAN IP on a physical device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
|
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
|
||||||
|
if (data?.type !== "invoice-send-reminder") return;
|
||||||
|
const invoiceId = data.invoiceId;
|
||||||
|
if (typeof invoiceId !== "string" || !invoiceId) return;
|
||||||
|
router.push(`/(app)/invoices/${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedules local iOS/Android notifications for draft invoice send reminders. */
|
||||||
|
export function InvoiceReminderSync() {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const invoicesQuery = api.invoices.getAll.useQuery(
|
||||||
|
{ status: "draft" },
|
||||||
|
{ staleTime: 60_000 },
|
||||||
|
);
|
||||||
|
const wasBackgrounded = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoicesQuery.data) return;
|
||||||
|
void syncInvoiceSendReminders(invoicesQuery.data);
|
||||||
|
}, [invoicesQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||||
|
if (nextState === "background" || nextState === "inactive") {
|
||||||
|
wasBackgrounded.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||||
|
wasBackgrounded.current = false;
|
||||||
|
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [utils.invoices.getAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response) => {
|
||||||
|
openInvoiceFromNotification(
|
||||||
|
response.notification.request.content.data as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
|
if (!response) return;
|
||||||
|
openInvoiceFromNotification(
|
||||||
|
response.notification.request.content.data as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => responseSubscription.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
import { LogoMark } from "@/components/Logo";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
export function LoadingScreen({ message = "Loading…" }: { message?: string }) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<LogoMark />
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { StyleSheet, Text, View, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
const markSource = require("@/assets/images/icon.png");
|
||||||
|
|
||||||
|
type LogoSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
const widths: Record<LogoSize, number> = {
|
||||||
|
xs: 104,
|
||||||
|
sm: 140,
|
||||||
|
md: 180,
|
||||||
|
lg: 220,
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoProps = {
|
||||||
|
size?: LogoSize;
|
||||||
|
style?: ViewStyle;
|
||||||
|
/** Force the light wordmark for dark backgrounds (e.g. status bar chrome). */
|
||||||
|
onDark?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full beenvoice wordmark from web `public/beenvoice-logo.png` */
|
||||||
|
export function Logo({ size = "md", style, onDark }: LogoProps) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
const width = widths[size];
|
||||||
|
const height = width * (436 / 2970);
|
||||||
|
const useDarkAsset = onDark ?? isDark;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.row, styles.noShrink, style]}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
useDarkAsset
|
||||||
|
? require("@/assets/images/beenvoice-logo-dark.png")
|
||||||
|
: require("@/assets/images/beenvoice-logo.png")
|
||||||
|
}
|
||||||
|
style={{ width, height }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square dollar mark from Icon Composer export (1024×1024 PNG). */
|
||||||
|
export function LogoMark({
|
||||||
|
size = 32,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}) {
|
||||||
|
const fromStyle =
|
||||||
|
typeof style?.width === "number"
|
||||||
|
? style.width
|
||||||
|
: typeof style?.height === "number"
|
||||||
|
? style.height
|
||||||
|
: undefined;
|
||||||
|
const dimension = fromStyle ?? size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.markBox,
|
||||||
|
{ width: dimension, height: dimension, aspectRatio: 1 },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={markSource}
|
||||||
|
style={{ width: dimension, height: dimension }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadingText({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: object;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text style={[styles.heading, { color: colors.foreground }, style]}>{children}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
noShrink: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
markBox: {
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Redirect new users into onboarding until they complete setup. */
|
||||||
|
export function OnboardingGate() {
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
|
||||||
|
if (statusQuery.data.completed) return;
|
||||||
|
redirected.current = true;
|
||||||
|
router.push("/(app)/onboarding" as never);
|
||||||
|
}, [statusQuery.data, statusQuery.isLoading]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { tabLayout } from "@/lib/tab-layout";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type PageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Title block — scrolls with tab screen content. */
|
||||||
|
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tabLayout.pageHeader}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { isValidPin } from "@/lib/app-lock";
|
||||||
|
|
||||||
|
type PinPromptProps = {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
requireConfirmation?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (pin: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PinPrompt({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Continue",
|
||||||
|
requireConfirmation = false,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: PinPromptProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [confirmPin, setConfirmPin] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setPin("");
|
||||||
|
setConfirmPin("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!isValidPin(pin)) {
|
||||||
|
setError("PIN must be 4–6 digits");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireConfirmation && pin !== confirmPin) {
|
||||||
|
setError("PINs do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onCancel}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||||
|
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={pin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{requireConfirmation ? (
|
||||||
|
<TextInput
|
||||||
|
value={confirmPin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setConfirmPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="Confirm PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button title="Cancel" variant="secondary" onPress={onCancel} />
|
||||||
|
<Button title={confirmLabel} onPress={handleSubmit} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.35)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, type RefreshControlProps } from "react-native";
|
||||||
|
|
||||||
|
type PullToRefreshProps = Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
|
||||||
|
onRefresh: () => Promise<unknown> | unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the native refresh indicator tied to an actual pull gesture.
|
||||||
|
* Query `isRefetching` also covers background polling and invalidations, which
|
||||||
|
* can repeatedly move an offscreen iOS scroll view when used as `refreshing`.
|
||||||
|
*/
|
||||||
|
export function PullToRefresh({ onRefresh, ...props }: PullToRefreshProps) {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [onRefresh, refreshing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RefreshControl
|
||||||
|
{...props}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => void handleRefresh()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyleSheet, type ViewStyle } from "react-native";
|
||||||
|
import { SafeAreaView, type Edge } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScreenProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: ViewStyle;
|
||||||
|
/**
|
||||||
|
* Safe area edges to pad. Default: top + sides (Dynamic Island / notch).
|
||||||
|
* Tab screens usually omit bottom — the tab bar handles home-indicator spacing.
|
||||||
|
*/
|
||||||
|
edges?: Edge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full-screen wrapper that respects Dynamic Island, notch, and side insets. */
|
||||||
|
export function Screen({ children, style, edges = ["top", "left", "right"] }: ScreenProps) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.screen, style]} edges={edges}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth / modal screens that aren't inside a tab bar. */
|
||||||
|
export function FullScreen({ children, style }: Omit<ScreenProps, "edges">) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.screen, style]} edges={["top", "bottom", "left", "right"]}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
import { isRateLimitError } from "@/lib/trpc-errors";
|
||||||
|
|
||||||
|
/** Refetch auth session when the app returns to the foreground. */
|
||||||
|
export function SessionSync() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { activeAccountId, clearActiveAccount } = useAccounts();
|
||||||
|
const { data: session, refetch } = useSession();
|
||||||
|
const wasBackgrounded = useRef(false);
|
||||||
|
const resettingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||||
|
if (nextState === "background" || nextState === "inactive") {
|
||||||
|
wasBackgrounded.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||||
|
wasBackgrounded.current = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await refetch();
|
||||||
|
const next = await authClient.getSession();
|
||||||
|
if (next.error && isRateLimitError(next.error)) return;
|
||||||
|
if (next.data?.user) return;
|
||||||
|
if (!session?.user || resettingRef.current) return;
|
||||||
|
|
||||||
|
resettingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession: refetch,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
resettingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Platform } from "react-native";
|
||||||
|
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppLock } from "@/contexts/AppLockContext";
|
||||||
|
import {
|
||||||
|
clearPendingShortcut,
|
||||||
|
peekPendingShortcut,
|
||||||
|
subscribeShortcutQueue,
|
||||||
|
} from "@/lib/shortcut-queue";
|
||||||
|
import {
|
||||||
|
DEFAULT_CLOCK_DESCRIPTION,
|
||||||
|
resolveClockDescription,
|
||||||
|
resolveEffectiveHourlyRate,
|
||||||
|
} from "@/lib/time-clock";
|
||||||
|
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
|
||||||
|
import {
|
||||||
|
endTimeClockLiveActivity,
|
||||||
|
syncTimeClockLiveActivity,
|
||||||
|
} from "@/lib/time-clock-live-activity";
|
||||||
|
import type { ParsedShortcut } from "@/lib/shortcuts";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes queued shortcut actions once the user is signed in, unlocked, and data is ready.
|
||||||
|
*/
|
||||||
|
export function ShortcutHandler() {
|
||||||
|
const { activeAccountId } = useAccounts();
|
||||||
|
const { isLocked } = useAppLock();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
const runningQuery = api.timeEntries.getRunning.useQuery();
|
||||||
|
const [pending, setPending] = useState<ParsedShortcut | null>(null);
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
|
||||||
|
const clockIn = api.timeEntries.clockIn.useMutation();
|
||||||
|
const clockOut = api.timeEntries.clockOut.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const next = await peekPendingShortcut();
|
||||||
|
if (!cancelled) setPending(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
return subscribeShortcutQueue(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending || !activeAccountId || isLocked) return;
|
||||||
|
if (clientsQuery.isLoading || runningQuery.isLoading) return;
|
||||||
|
if (processingRef.current) return;
|
||||||
|
|
||||||
|
processingRef.current = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (pending.action === "open-timer") {
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.action === "clock-out") {
|
||||||
|
if (!runningQuery.data) {
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
Alert.alert("No timer running", "There is nothing to clock out.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clockOut.mutateAsync({});
|
||||||
|
await endTimeClockLiveActivity();
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getRunning.invalidate(),
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.action === "clock-in") {
|
||||||
|
if (runningQuery.data) {
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId =
|
||||||
|
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
|
||||||
|
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
|
||||||
|
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
|
||||||
|
|
||||||
|
await clockIn.mutateAsync({
|
||||||
|
clientId: clientId || "",
|
||||||
|
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
|
||||||
|
rate: rate ?? undefined,
|
||||||
|
});
|
||||||
|
await utils.timeEntries.getRunning.invalidate();
|
||||||
|
const running = await utils.timeEntries.getRunning.fetch();
|
||||||
|
if (running) {
|
||||||
|
const seconds = Math.floor(
|
||||||
|
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||||
|
);
|
||||||
|
await syncTimeClockLiveActivity(running, seconds);
|
||||||
|
}
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await clearPendingShortcut();
|
||||||
|
setPending(null);
|
||||||
|
Alert.alert(
|
||||||
|
pending.action === "clock-out" ? "Clock out failed" : "Clock in failed",
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
);
|
||||||
|
router.push("/(app)/timer");
|
||||||
|
} finally {
|
||||||
|
processingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [
|
||||||
|
activeAccountId,
|
||||||
|
clockIn,
|
||||||
|
clockOut,
|
||||||
|
clientsQuery.data,
|
||||||
|
clientsQuery.isLoading,
|
||||||
|
isLocked,
|
||||||
|
pending,
|
||||||
|
runningQuery.data,
|
||||||
|
runningQuery.isLoading,
|
||||||
|
utils,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { enqueueShortcut } from "@/lib/shortcut-queue";
|
||||||
|
import { parseShortcutUrl } from "@/lib/shortcuts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures shortcut deep links as early as possible (before auth / tabs mount).
|
||||||
|
* Mounted at the app root inside AppServices.
|
||||||
|
*/
|
||||||
|
export function ShortcutLinkCapture() {
|
||||||
|
useEffect(() => {
|
||||||
|
function capture(url: string | null | undefined) {
|
||||||
|
const parsed = parseShortcutUrl(url);
|
||||||
|
if (parsed) {
|
||||||
|
void enqueueShortcut(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Linking.getInitialURL().then(capture);
|
||||||
|
|
||||||
|
const subscription = Linking.addEventListener("url", ({ url }) => {
|
||||||
|
capture(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { SHORTCUT_URLS } from "@/lib/shortcuts";
|
||||||
|
|
||||||
|
const SHORTCUT_ACTIONS = [
|
||||||
|
{ title: "Clock In", subtitle: "Start timer with your last client", url: SHORTCUT_URLS.clockIn },
|
||||||
|
{ title: "Clock Out", subtitle: "Stop the running timer", url: SHORTCUT_URLS.clockOut },
|
||||||
|
{ title: "Open Time Clock", subtitle: "Jump to the timer tab", url: SHORTCUT_URLS.openTimer },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ShortcutsSetupCard() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
if (Platform.OS !== "ios") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stack}>
|
||||||
|
<Text style={[styles.lead, { color: colors.mutedForeground }]}>
|
||||||
|
beenvoice actions appear when you build a shortcut — they are not pre-installed in your
|
||||||
|
library. After installing a native build (not Expo Go), open the app once, then:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[styles.steps, { borderColor: colors.border, backgroundColor: colors.muted }]}>
|
||||||
|
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||||
|
1. Open the Shortcuts app → tap + → Add Action
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||||
|
2. Search <Text style={styles.emphasis}>beenvoice</Text> (or Clock In / Clock Out)
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||||
|
3. Pick Clock In, Clock Out, or Open Time Clock
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
You can also ask Siri: “Clock in with beenvoice”. Pick a client once on the Timer tab
|
||||||
|
before your first clock-in shortcut.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
|
||||||
|
<Text style={styles.emphasis}>bun run ios</Text>). Shortcuts require iOS 18+.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{SHORTCUT_ACTIONS.map((action) => (
|
||||||
|
<Pressable
|
||||||
|
key={action.title}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void Linking.openURL(action.url)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.actionRow,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: pressed ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.actionCopy}>
|
||||||
|
<Text style={[styles.actionTitle, { color: colors.foreground }]}>{action.title}</Text>
|
||||||
|
<Text style={[styles.actionSubtitle, { color: colors.mutedForeground }]}>
|
||||||
|
{action.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="open-outline" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Open Shortcuts app"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => void Linking.openURL("shortcuts://")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
stack: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: spacing.sm,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
actionCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
actionTitle: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
actionSubtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { StyleSheet, Text } from "react-native";
|
||||||
|
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Web `StatsCard` — glass card, border-0, shadow-md, p-6 */
|
||||||
|
export function StatCard({ label, value, hint }: StatCardProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="stat" style={styles.card}>
|
||||||
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
{hint ? (
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { getStatusColor, statusLabels, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
const color = getStatusColor(status, isDark);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: `${color}22` }]}>
|
||||||
|
<Text style={[styles.text, { color }]}>{statusLabels[status]}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
height: 22,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: fonts.bodyBold,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Text, TextProps } from './Themed';
|
||||||
|
|
||||||
|
export function MonoText(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { ReactNode, useRef } from "react";
|
||||||
|
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
|
||||||
|
import Swipeable, {
|
||||||
|
type SwipeableMethods,
|
||||||
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedStyle,
|
||||||
|
type SharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { resolveActionForeground } from "@/lib/action-contrast";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
export type SwipeAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwipeableRowProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
actions: SwipeAction[];
|
||||||
|
enabled?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
contentStyle?: PressableProps["style"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwipeActionsProps = {
|
||||||
|
actions: SwipeAction[];
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
onActionPress: (action: SwipeAction) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SwipeActions({ actions, progress, onActionPress }: SwipeActionsProps) {
|
||||||
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||||
|
const revealStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(progress.value, [0, 0.02, 0.15], [0, 0, 1], "clamp"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.actions, revealStyle]}>
|
||||||
|
{actions.map((action) => {
|
||||||
|
const foreground = resolveActionForeground(action.backgroundColor, action.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={action.key}
|
||||||
|
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||||
|
onPress={() => onActionPress(action)}
|
||||||
|
>
|
||||||
|
<Ionicons name={action.icon} size={20} color={foreground} />
|
||||||
|
<Text style={[styles.actionLabel, { color: foreground }]}>{action.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwipeableRow({
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
enabled = true,
|
||||||
|
backgroundColor,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
contentStyle,
|
||||||
|
}: SwipeableRowProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||||
|
const rowBackground = backgroundColor ?? colors.background;
|
||||||
|
const swipeRef = useRef<SwipeableMethods>(null);
|
||||||
|
const rowOpenRef = useRef(false);
|
||||||
|
const suppressPressUntilRef = useRef(0);
|
||||||
|
|
||||||
|
function suppressContentPress() {
|
||||||
|
suppressPressUntilRef.current = Date.now() + 350;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentPress() {
|
||||||
|
if (!onPress) return;
|
||||||
|
|
||||||
|
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
|
||||||
|
swipeRef.current?.close();
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (!onPress && !onLongPress) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.row,
|
||||||
|
{ backgroundColor: rowBackground },
|
||||||
|
typeof contentStyle === "function" ? undefined : contentStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleContentPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
style={(state) => [
|
||||||
|
styles.row,
|
||||||
|
{ backgroundColor: rowBackground },
|
||||||
|
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionPress(action: SwipeAction) {
|
||||||
|
suppressContentPress();
|
||||||
|
swipeRef.current?.close();
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
action.onPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRightActions(progress: SharedValue<number>) {
|
||||||
|
return (
|
||||||
|
<SwipeActions
|
||||||
|
actions={actions}
|
||||||
|
progress={progress}
|
||||||
|
onActionPress={handleActionPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled || actions.length === 0) {
|
||||||
|
return renderContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeRef}
|
||||||
|
containerStyle={styles.container}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
overshootRight={false}
|
||||||
|
onSwipeableOpenStartDrag={suppressContentPress}
|
||||||
|
onSwipeableCloseStartDrag={suppressContentPress}
|
||||||
|
onSwipeableWillOpen={() => {
|
||||||
|
suppressContentPress();
|
||||||
|
rowOpenRef.current = true;
|
||||||
|
}}
|
||||||
|
onSwipeableWillClose={suppressContentPress}
|
||||||
|
onSwipeableClose={() => {
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
|
||||||
|
import { TopChromeBar } from "@/components/TopChromeBar";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type TabPageProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
showMoreBack?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tab root — pinned top chrome, scrollable body below. */
|
||||||
|
export function TabPage({ children, showMoreBack = false }: TabPageProps) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
<TopChromeBar showMoreBack={showMoreBack} />
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useScrollToTop } from "expo-router";
|
||||||
|
import { useRef, type ReactNode } from "react";
|
||||||
|
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
import { tabLayout } from "@/lib/tab-layout";
|
||||||
|
import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
type TabScrollViewProps = ScrollViewProps & {
|
||||||
|
/** Rendered at the top of scroll content (scrolls with the page). */
|
||||||
|
header?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab screen scroll view. Top chrome (logo / account) is pinned in TabPage;
|
||||||
|
* the page header and body scroll together here.
|
||||||
|
*/
|
||||||
|
export function TabScrollView({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
contentContainerStyle,
|
||||||
|
refreshControl,
|
||||||
|
style,
|
||||||
|
bounces,
|
||||||
|
alwaysBounceVertical,
|
||||||
|
...props
|
||||||
|
}: TabScrollViewProps) {
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const bottomPadding = useTabScreenScrollPadding();
|
||||||
|
const canRefresh = Boolean(refreshControl);
|
||||||
|
|
||||||
|
useScrollToTop(scrollRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
style={[styles.scroll, style]}
|
||||||
|
contentContainerStyle={[
|
||||||
|
tabLayout.scrollContent,
|
||||||
|
{ paddingBottom: bottomPadding },
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
automaticallyAdjustContentInsets={false}
|
||||||
|
automaticallyAdjustKeyboardInsets={false}
|
||||||
|
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||||
|
bounces={bounces ?? canRefresh}
|
||||||
|
alwaysBounceVertical={alwaysBounceVertical ?? canRefresh}
|
||||||
|
refreshControl={refreshControl}
|
||||||
|
scrollIndicatorInsets={{ bottom: bottomPadding }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<View style={tabLayout.scrollBody}>{children}</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about Light and Dark modes:
|
||||||
|
* https://docs.expo.io/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||||
|
|
||||||
|
import { useColorScheme } from './useColorScheme';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
type ThemeProps = {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextProps = ThemeProps & DefaultText['props'];
|
||||||
|
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme();
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text(props: TextProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function View(props: ViewProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||