Compare commits

..
50 Commits
Author SHA1 Message Date
soconnor f0b1a7355e Improve dashboard sidebar layout 2026-08-18 00:44:48 -04:00
soconnor 57a985bcd5 chore: bump iOS build to 29 2026-08-17 22:54:18 -04:00
soconnor 7be4bb2abe feat: add business brand assets and health checks 2026-08-17 20:33:04 -04:00
soconnor 5c9fbe6dc2 Fix worker domain dependency 2026-08-17 18:44:11 -04:00
soconnor 70c08054fb Make scheduling and dates timezone-safe 2026-08-17 18:15:39 -04:00
soconnor 1853eaa963 Add Mailpit email transport 2026-08-17 16:35:14 -04:00
soconnor 67ab6b78bd Add scheduled invoice delivery 2026-08-17 13:54:45 -04:00
soconnor 29589c1f32 Render signup policy dynamically 2026-08-17 01:55:41 -04:00
soconnor dafa62b9bb Make Coolify service ports configurable 2026-08-17 01:14:39 -04:00
soconnor 24a1307a96 Add PostgreSQL-backed background worker 2026-08-17 01:02:44 -04:00
soconnor 9929d7321d Polish mobile and web experience 2026-08-17 00:18:55 -04:00
soconnor 6c74436092 Convert Beenvoice to a Turborepo monorepo 2026-08-16 22:13:42 -04:00
soconnor d057ba208d Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
2026-08-16 21:42:59 -04:00
soconnor 86f8987dff Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
2026-08-16 21:42:59 -04:00
soconnor 068a51b46b Initialize Beenvoice workspace 2026-08-16 21:42:57 -04:00
soconnor 5fa30f365f Make iOS release signing deterministic 2026-08-15 14:18:09 -04:00
soconnor b9c7fb325a Add in-app account deletion 2026-08-15 13:46:47 -04:00
soconnor 1e7174fa60 Add permanent account deletion 2026-08-15 13:46:42 -04:00
soconnor 9eadb0d7fb Document public App Store demo login 2026-08-15 00:17:57 -04:00
soconnor 5f6987b84b Restore public demo review password 2026-08-15 00:17:57 -04:00
soconnor fa42ffb767 Add App Store review screenshots 2026-08-14 19:48:21 -04:00
soconnor d2fa050c05 Bump iOS build to 27 2026-08-14 18:51:49 -04:00
soconnor f493fd20f2 Upgrade to Expo 57 and add parity smoke tests 2026-08-14 16:45:02 -04:00
soconnor 31ae51cf9e Add offline caching and refine Live Activity 2026-08-14 16:26:43 -04:00
soconnor fa3b9bf6a0 update app.json 2026-07-06 21:12:22 -04:00
soconnor ae7350dae6 Prevent swipe rows from opening items 2026-07-02 00:52:50 -04:00
soconnor 530d0dc34d Refresh home and more navigation 2026-07-01 02:46:39 -04:00
soconnor 6497ee4dec Wire live activity widget patches 2026-07-01 02:45:30 -04:00
soconnor 107b593002 Center live activity timer layout 2026-07-01 02:37:58 -04:00
soconnor b7eef743bd Fix mobile account session selection 2026-06-29 22:01:41 -04:00
soconnor 9498a09b5b Bump iOS build number 2026-06-29 18:40:11 -04:00
soconnor 0aa1b0b431 Send Better Auth session token with tRPC 2026-06-29 18:38:47 -04:00
soconnor 57e1aa658a Stabilize mobile auth session handling 2026-06-29 17:58:50 -04:00
soconnor 477459edd4 Fix iOS Podfile generation for receipt OCR 2026-06-29 01:44:12 -04:00
soconnor 21268ddd39 Refine receipt item expense selection 2026-06-29 01:38:00 -04:00
soconnor 7bc8559b44 Expose mobile More tools 2026-06-29 01:38:00 -04:00
soconnor 8ad4210908 Add mobile expenses and receipt OCR 2026-06-29 01:35:00 -04:00
soconnorandCursor 8a3f498874 Fix TimeClockPanel JSX structure that broke the iOS release bundle.
Close the client options View before the clock-in fragment ends so Metro can compile the archive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:41:55 -04:00
soconnorandCursor 6762a9bff3 Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.
Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:40:48 -04:00
soconnorandCursor e17c4c6854 Sync ExpoWidgetsTarget version and build number with the main app on prebuild.
Fixes App Store Connect warnings when the widget extension CFBundle version
does not match the containing application.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:12:19 -04:00
soconnorandCursor 355b14faef Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing
fixes for distribution export. Add mobile invoice PDF preview, compact line
items, and more reliable shortcut deep-link handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:08:20 -04:00
soconnorandCursor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00
soconnorandCursor 0b2d65a4e9 Add Authentik sign-in, fix tab scroll insets, and polish multi-account auth.
Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved
sessions when adding accounts. Tab screens get proper chrome layout and tab-bar
clearance with scrollable page headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 02:27:31 -04:00
soconnorandCursor 3daf123399 Add App Store Connect submission guide with copy and privacy answers.
Document metadata, descriptions, review notes, demo credentials, and pre-submission checklist for the iOS release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:34:44 -04:00
soconnorandCursor 32ffe782ea Fix Live Activity lock screen rendering and polish multi-account auth.
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:23:36 -04:00
soconnorandCursor e6ea3d7c5d Link Expo project to EAS for TestFlight builds.
Register the app with EAS, including the Live Activity widget extension and biometric permissions needed for production iOS builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:51:20 -04:00
soconnorandCursor d3b73464e4 Polish Live Activity branding and add EAS build config.
Use brand mark and wordmark images in the time clock Live Activity, migrate file copies to the modern expo-file-system File API, and add eas.json for TestFlight production builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:39:01 -04:00
soconnorandCursor 6d2711e36e Polish mobile app for App Store review and expand CRUD.
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:14:58 -04:00
soconnorandCursor 14c880123c Add beenvoice mobile companion app with full dark mode support.
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 22:36:37 -04:00
soconnor 8a7a8df477 Initial commit
Generated by create-expo-app 4.0.0.
2026-06-17 15:44:12 -04:00
632 changed files with 33280 additions and 4403 deletions
+4 -5
View File
@@ -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
} }
] ]
} }
+13
View File
@@ -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
+14 -41
View File
@@ -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
+9 -461
View File
@@ -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.
+70 -19
View File
@@ -1,24 +1,47 @@
# 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/worker/package.json apps/worker/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY packages/email/package.json packages/email/package.json
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
for attempt in 1 2 3; do \
bun install --frozen-lockfile --filter @beenvoice/web && exit 0; \
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
done; \
exit 1
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during FROM base AS worker-install
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun. COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY packages/email/package.json packages/email/package.json
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
for attempt in 1 2 3; do \
bun install --frozen-lockfile --filter @beenvoice/worker && exit 0; \
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
done; \
exit 1
# Next's production build runs under Node because Bun can fail during the
# 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 +49,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 +59,42 @@ 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/packages/email ./packages/email
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./migrate.ts COPY --from=build /app/apps/web/.next ./apps/web/.next
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
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e 'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)'
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
FROM base AS worker
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
COPY --from=worker-install /app ./
COPY apps/web/src ./apps/web/src
COPY apps/web/tsconfig.json ./apps/web/tsconfig.json
COPY apps/worker ./apps/worker
COPY packages/domain ./packages/domain
COPY packages/email ./packages/email
RUN ln -s ../worker/node_modules apps/web/node_modules
USER bun
WORKDIR /app/apps/worker
CMD ["bun", "run", "start"]
# Keep the web application as the default Dockerfile target.
FROM release AS final
+21
View File
@@ -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.
+53 -245
View File
@@ -1,278 +1,86 @@
![beenvoice Logo](public/beenvoice-logo.png) # 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
│ └── worker/ # PostgreSQL-backed scheduler and background jobs
├── packages/
│ ├── domain/ # Platform-neutral shared rules and parsing
│ └── email/ # Resend and SMTP/Mailpit delivery adapter
├── 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, Expo Metro on port 8082, and the background worker. 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
bun run email:preview # sends a PDF-bearing message to local Mailpit
``` ```
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 and exposes separate `final` (web) and `worker` targets. The Compose stack starts both; the web container runs migrations before serving requests and the worker tolerates that short startup race.
|---------|-----------|-----------------|
| `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 | - [Worker architecture](./apps/worker/README.md)
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development | - [Shared domain package](./packages/domain/README.md)
- [Email delivery package](./packages/email/README.md)
## 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
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+4
View File
@@ -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
+44
View File
@@ -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
+31
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
+7
View File
@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
+34
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+150
View File
@@ -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)
+140
View File
@@ -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": "29",
"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"
}
}
+85
View File
@@ -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>
);
}
+355
View File
@@ -0,0 +1,355 @@
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";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
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.businessRow}>
{hasMobileBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
style={styles.businessIcon}
/>
) : null}
<View style={styles.businessCopy}>
<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>
</View>
</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",
},
businessRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
businessCopy: {
flex: 1,
minWidth: 0,
gap: 4,
},
businessIcon: {
width: 44,
height: 44,
borderRadius: 10,
},
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,
},
});
+630
View File
@@ -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,
},
});
+468
View File
@@ -0,0 +1,468 @@
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";
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
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}
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<DetailRow
label="Scheduled send"
value={formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? "UTC",
)}
/>
) : 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,505 @@
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";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
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(() =>
calendarDateFromLocalDate(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: calendarDateFromLocalDate(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,
},
});
+339
View File
@@ -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,
},
});
+467
View File
@@ -0,0 +1,467 @@
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";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
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(() =>
calendarDateFromLocalDate(new Date()),
);
const [dueDate, setDueDate] = useState(() =>
defaultDueDate(calendarDateFromLocalDate(new Date())),
);
const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>(() =>
isBlank
? []
: [
{
date: calendarDateFromLocalDate(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: calendarDateFromLocalDate(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,372 @@
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 { DateTimeField } from "@/components/ui/DateTimeField";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
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 [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const profileQuery = api.settings.getProfile.useQuery();
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
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 scheduleInvoice = api.email.scheduleInvoice.useMutation({
onSuccess: async (data) => {
await utils.invoices.getById.invalidate({ id: id ?? "" });
await utils.invoices.getAll.invalidate();
Alert.alert(
"Invoice scheduled",
`It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
[
{
text: "OK",
onPress: () => router.replace(`/(app)/invoices/${id}`),
},
],
);
},
onError: (err) => Alert.alert("Could not schedule invoice", err.message),
});
const cancelScheduledInvoice = api.email.cancelScheduledInvoice.useMutation({
onSuccess: async () => {
await utils.invoices.getById.invalidate({ id: id ?? "" });
await utils.invoices.getAll.invalidate();
Alert.alert("Scheduled send cancelled");
},
onError: (err) =>
Alert.alert("Could not cancel scheduled send", 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,
});
}
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
let instant: Date;
try {
instant = zonedDateTimeToInstant(
toLocalDateTimeInputValue(scheduledAt),
timeZone,
"earlier",
);
} catch (error) {
Alert.alert(
"Choose another time",
error instanceof Error ? error.message : "Invalid local time",
);
return;
}
if (instant.getTime() < Date.now() + 60_000) {
Alert.alert(
"Choose a future time",
"The scheduled time must be at least one minute from now.",
);
return;
}
scheduleInvoice.mutate({
invoiceId: invoice.id,
scheduledAt: instant,
timeZone,
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>
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<Card title="Scheduled send">
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
{formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? timeZone,
)}{" "}
({invoice.scheduledSendTimeZone ?? timeZone})
</Text>
<Button
title="Cancel scheduled send"
variant="secondary"
onPress={() =>
cancelScheduledInvoice.mutate({ invoiceId: invoice.id })
}
loading={cancelScheduledInvoice.isPending}
/>
</Card>
) : null}
<Card title="Send later">
<DateTimeField
label="Send date and time"
value={scheduledAt}
minimumDate={new Date(Date.now() + 60_000)}
maximumDate={new Date(2100, 0, 1)}
onChange={setScheduledAt}
/>
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
Time zone: {timeZone}. The exact instant is preserved across
devices and daylight saving changes.
</Text>
<Button
title="Schedule invoice"
variant="secondary"
onPress={handleSchedule}
loading={scheduleInvoice.isPending}
disabled={!clientEmail || invoice.items.length === 0}
/>
</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",
},
});
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
export default function MoreLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
/>
);
}
@@ -0,0 +1,417 @@
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";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
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: calendarDateFromLocalDate(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,553 @@
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";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
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 key = formatCalendarDate(expense.date, {
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,
},
});
+276
View File
@@ -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}`;
}
+125
View File
@@ -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,
},
});
+122
View File
@@ -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,
},
});
+117
View File
@@ -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,
},
});
+703
View File
@@ -0,0 +1,703 @@
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 46 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}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</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,
},
});
+186
View File
@@ -0,0 +1,186 @@
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";
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[], timeZone: string) {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const parts = getZonedDateTimeParts(d, timeZone);
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const list = groups.get(dateKey) ?? [];
list.push(entry);
groups.set(dateKey, list);
}
return Array.from(groups.entries()).map(
([, groupedEntries]) =>
[
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone,
}),
groupedEntries,
] as const,
);
}
export default function TimeEntriesScreen() {
const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery();
const profileQuery = api.settings.getProfile.useQuery();
const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data],
);
const grouped = useMemo(
() =>
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
[completed, profileQuery.data?.timeZone],
);
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,
},
});
+139
View File
@@ -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,
},
});
+33
View File
@@ -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>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
/>
);
}
+154
View File
@@ -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&apos;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,
},
});
+12
View File
@@ -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" />;
}
+218
View File
@@ -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,
},
});
+213
View File
@@ -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,
},
});
+159
View File
@@ -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,
},
});
+231
View File
@@ -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&apos;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,
},
});
+39
View File
@@ -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;
}
}`;
+40
View File
@@ -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',
},
});
+129
View File
@@ -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

+111
View File
@@ -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"
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+374
View File
@@ -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,
},
});
+34
View File
@@ -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",
},
});
+178
View File
@@ -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%",
},
});
+232
View File
@@ -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&apos;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,
},
});
+134
View File
@@ -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&apos;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,
},
});
+76
View File
@@ -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',
},
});
+22
View File
@@ -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);
}
}}
/>
);
}
+60
View File
@@ -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,
},
});
+112
View File
@@ -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&apos;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,94 @@
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { AppState, Platform, type AppStateStatus } from "react-native";
import {
ensureNotificationPermissions,
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);
const [remotePushReady, setRemotePushReady] = useState(false);
const registerPushToken = api.notifications.registerPushToken.useMutation();
useEffect(() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return;
void (async () => {
if (!(await ensureNotificationPermissions())) return;
const projectId =
Constants.easConfig?.projectId ??
(Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)
?.projectId;
if (!projectId) return;
const { data: token } = await Notifications.getExpoPushTokenAsync({
projectId,
});
await registerPushToken.mutateAsync({ token, platform: Platform.OS });
setRemotePushReady(true);
})().catch(() => {
// Local reminders remain available when remote push registration is unavailable.
});
}, [registerPushToken]);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
}, [invoicesQuery.data, remotePushReady]);
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;
}
+45
View File
@@ -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,
},
});
+110
View File
@@ -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,
},
});
+19
View File
@@ -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;
}
+35
View File
@@ -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,
},
});
+157
View File
@@ -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 46 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,
},
});
+34
View File
@@ -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()}
/>
);
}
+38
View File
@@ -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",
},
});
+52
View File
@@ -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;
}
+154
View File
@@ -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,
},
});
+45
View File
@@ -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,
},
});
+33
View File
@@ -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,
},
});
+5
View File
@@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}
+204
View File
@@ -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,
},
});
+36
View File
@@ -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",
},
});
+65
View File
@@ -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",
},
});
+45
View File
@@ -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} />;
}

Some files were not shown because too many files have changed in this diff Show More