From 936c430de8ed7e5b0bcaec82c89a9ea1c5ebedc6 Mon Sep 17 00:00:00 2001
From: Florian2807 <84639717+Florian2807@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:34:40 +0200
Subject: [PATCH 1/3] Fix Better Auth account schema compatibility
---
.../db/drizzle/0013_gray_thunderbolts.sql | 3 +
packages/db/drizzle/meta/0013_snapshot.json | 2821 +++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
packages/db/src/schema.ts | 4 +-
4 files changed, 2833 insertions(+), 2 deletions(-)
create mode 100644 packages/db/drizzle/0013_gray_thunderbolts.sql
create mode 100644 packages/db/drizzle/meta/0013_snapshot.json
diff --git a/packages/db/drizzle/0013_gray_thunderbolts.sql b/packages/db/drizzle/0013_gray_thunderbolts.sql
new file mode 100644
index 0000000..087434a
--- /dev/null
+++ b/packages/db/drizzle/0013_gray_thunderbolts.sql
@@ -0,0 +1,3 @@
+DROP INDEX "account_issuer_unique";--> statement-breakpoint
+ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL;--> statement-breakpoint
+CREATE UNIQUE INDEX "account_provider_unique" ON "account" USING btree ("provider_id","account_id");
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json
new file mode 100644
index 0000000..60fdc13
--- /dev/null
+++ b/packages/db/drizzle/meta/0013_snapshot.json
@@ -0,0 +1,2821 @@
+{
+ "id": "63508c8c-f7f0-46ef-b1a6-954ae5fda4e2",
+ "prevId": "52f8262c-45b6-409a-a52c-da9e5f4f8076",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_idx": {
+ "name": "account_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_provider_unique": {
+ "name": "account_provider_unique",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.currency_quote": {
+ "name": "currency_quote",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base": {
+ "name": "base",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rates": {
+ "name": "rates",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "currency_quote_user_idx": {
+ "name": "currency_quote_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "currency_quote_expiry_idx": {
+ "name": "currency_quote_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "currency_quote_user_id_user_id_fk": {
+ "name": "currency_quote_user_id_user_id_fk",
+ "tableFrom": "currency_quote",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.expense_payment": {
+ "name": "expense_payment",
+ "schema": "",
+ "columns": {
+ "expense_id": {
+ "name": "expense_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_amount_minor": {
+ "name": "source_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "expense_payment_user_idx": {
+ "name": "expense_payment_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "expense_payment_expense_id_expense_id_fk": {
+ "name": "expense_payment_expense_id_expense_id_fk",
+ "tableFrom": "expense_payment",
+ "tableTo": "expense",
+ "columnsFrom": [
+ "expense_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "expense_payment_user_id_user_id_fk": {
+ "name": "expense_payment_user_id_user_id_fk",
+ "tableFrom": "expense_payment",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "expense_payment_expense_id_user_id_pk": {
+ "name": "expense_payment_expense_id_user_id_pk",
+ "columns": [
+ "expense_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.expense_split": {
+ "name": "expense_split",
+ "schema": "",
+ "columns": {
+ "expense_id": {
+ "name": "expense_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_amount_minor": {
+ "name": "source_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "expense_split_user_idx": {
+ "name": "expense_split_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "expense_split_expense_id_expense_id_fk": {
+ "name": "expense_split_expense_id_expense_id_fk",
+ "tableFrom": "expense_split",
+ "tableTo": "expense",
+ "columnsFrom": [
+ "expense_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "expense_split_user_id_user_id_fk": {
+ "name": "expense_split_user_id_user_id_fk",
+ "tableFrom": "expense_split",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "expense_split_expense_id_user_id_pk": {
+ "name": "expense_split_expense_id_user_id_pk",
+ "columns": [
+ "expense_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.expense": {
+ "name": "expense",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "context_type": {
+ "name": "context_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "friendship_id": {
+ "name": "friendship_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payer_id": {
+ "name": "payer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_key": {
+ "name": "icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'other'"
+ },
+ "icon_manually_set": {
+ "name": "icon_manually_set",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_currency": {
+ "name": "source_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_amount_minor": {
+ "name": "source_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_mutation_id": {
+ "name": "client_mutation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "expense_idempotency_unique": {
+ "name": "expense_idempotency_unique",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "client_mutation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "expense_group_idx": {
+ "name": "expense_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "expense_friendship_idx": {
+ "name": "expense_friendship_idx",
+ "columns": [
+ {
+ "expression": "friendship_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "expense_active_group_idx": {
+ "name": "expense_active_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "expense_active_friendship_idx": {
+ "name": "expense_active_friendship_idx",
+ "columns": [
+ {
+ "expression": "friendship_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "expense_group_id_expense_group_id_fk": {
+ "name": "expense_group_id_expense_group_id_fk",
+ "tableFrom": "expense",
+ "tableTo": "expense_group",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "expense_friendship_id_friendship_id_fk": {
+ "name": "expense_friendship_id_friendship_id_fk",
+ "tableFrom": "expense",
+ "tableTo": "friendship",
+ "columnsFrom": [
+ "friendship_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "expense_created_by_user_id_fk": {
+ "name": "expense_created_by_user_id_fk",
+ "tableFrom": "expense",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "expense_payer_id_user_id_fk": {
+ "name": "expense_payer_id_user_id_fk",
+ "tableFrom": "expense",
+ "tableTo": "user",
+ "columnsFrom": [
+ "payer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.financial_revision": {
+ "name": "financial_revision",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "record_type": {
+ "name": "record_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "record_id": {
+ "name": "record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "financial_revision_version_unique": {
+ "name": "financial_revision_version_unique",
+ "columns": [
+ {
+ "expression": "record_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "financial_revision_actor_id_user_id_fk": {
+ "name": "financial_revision_actor_id_user_id_fk",
+ "tableFrom": "financial_revision",
+ "tableTo": "user",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.friendship": {
+ "name": "friendship",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_low_id": {
+ "name": "user_low_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_high_id": {
+ "name": "user_high_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_via": {
+ "name": "created_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "friendship_pair_unique": {
+ "name": "friendship_pair_unique",
+ "columns": [
+ {
+ "expression": "user_low_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_high_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "friendship_low_idx": {
+ "name": "friendship_low_idx",
+ "columns": [
+ {
+ "expression": "user_low_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "friendship_high_idx": {
+ "name": "friendship_high_idx",
+ "columns": [
+ {
+ "expression": "user_high_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "friendship_active_low_idx": {
+ "name": "friendship_active_low_idx",
+ "columns": [
+ {
+ "expression": "user_low_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "removed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "friendship_active_high_idx": {
+ "name": "friendship_active_high_idx",
+ "columns": [
+ {
+ "expression": "user_high_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "removed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "friendship_user_low_id_user_id_fk": {
+ "name": "friendship_user_low_id_user_id_fk",
+ "tableFrom": "friendship",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_low_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "friendship_user_high_id_user_id_fk": {
+ "name": "friendship_user_high_id_user_id_fk",
+ "tableFrom": "friendship",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_high_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_member": {
+ "name": "group_member",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "group_member_user_idx": {
+ "name": "group_member_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "group_member_active_user_idx": {
+ "name": "group_member_active_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "removed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_member_group_id_expense_group_id_fk": {
+ "name": "group_member_group_id_expense_group_id_fk",
+ "tableFrom": "group_member",
+ "tableTo": "expense_group",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "group_member_user_id_user_id_fk": {
+ "name": "group_member_user_id_user_id_fk",
+ "tableFrom": "group_member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_member_group_id_user_id_pk": {
+ "name": "group_member_group_id_user_id_pk",
+ "columns": [
+ "group_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_notification_preference": {
+ "name": "group_notification_preference",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "group_notification_preference_user_idx": {
+ "name": "group_notification_preference_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_notification_preference_group_id_expense_group_id_fk": {
+ "name": "group_notification_preference_group_id_expense_group_id_fk",
+ "tableFrom": "group_notification_preference",
+ "tableTo": "expense_group",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "group_notification_preference_user_id_user_id_fk": {
+ "name": "group_notification_preference_user_id_user_id_fk",
+ "tableFrom": "group_notification_preference",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_notification_preference_group_id_user_id_pk": {
+ "name": "group_notification_preference_group_id_user_id_pk",
+ "columns": [
+ "group_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.expense_group": {
+ "name": "expense_group",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_key": {
+ "name": "icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#4745B8'"
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "simplify_debts": {
+ "name": "simplify_debts",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "expense_group_created_by_user_id_fk": {
+ "name": "expense_group_created_by_user_id_fk",
+ "tableFrom": "expense_group",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_group_idx": {
+ "name": "invite_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_inviter_idx": {
+ "name": "invite_inviter_idx",
+ "columns": [
+ {
+ "expression": "inviter_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invite_group_id_expense_group_id_fk": {
+ "name": "invite_group_id_expense_group_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "expense_group",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "invite_inviter_id_user_id_fk": {
+ "name": "invite_inviter_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ledger_entry": {
+ "name": "ledger_entry",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_type": {
+ "name": "source_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "context_type": {
+ "name": "context_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "context_id": {
+ "name": "context_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "debtor_id": {
+ "name": "debtor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creditor_id": {
+ "name": "creditor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_currency": {
+ "name": "canonical_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_amount_minor": {
+ "name": "canonical_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reversal_of_id": {
+ "name": "reversal_of_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ledger_source_idx": {
+ "name": "ledger_source_idx",
+ "columns": [
+ {
+ "expression": "source_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ledger_context_idx": {
+ "name": "ledger_context_idx",
+ "columns": [
+ {
+ "expression": "context_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "context_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ledger_debtor_idx": {
+ "name": "ledger_debtor_idx",
+ "columns": [
+ {
+ "expression": "debtor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ledger_creditor_idx": {
+ "name": "ledger_creditor_idx",
+ "columns": [
+ {
+ "expression": "creditor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ledger_entry_debtor_id_user_id_fk": {
+ "name": "ledger_entry_debtor_id_user_id_fk",
+ "tableFrom": "ledger_entry",
+ "tableTo": "user",
+ "columnsFrom": [
+ "debtor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "ledger_entry_creditor_id_user_id_fk": {
+ "name": "ledger_entry_creditor_id_user_id_fk",
+ "tableFrom": "ledger_entry",
+ "tableTo": "user",
+ "columnsFrom": [
+ "creditor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ledger_valuation": {
+ "name": "ledger_valuation",
+ "schema": "",
+ "columns": {
+ "ledger_entry_id": {
+ "name": "ledger_entry_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount_minor": {
+ "name": "amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "ledger_valuation_user_idx": {
+ "name": "ledger_valuation_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ledger_valuation_ledger_entry_id_ledger_entry_id_fk": {
+ "name": "ledger_valuation_ledger_entry_id_ledger_entry_id_fk",
+ "tableFrom": "ledger_valuation",
+ "tableTo": "ledger_entry",
+ "columnsFrom": [
+ "ledger_entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "ledger_valuation_user_id_user_id_fk": {
+ "name": "ledger_valuation_user_id_user_id_fk",
+ "tableFrom": "ledger_valuation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ledger_valuation_ledger_entry_id_user_id_pk": {
+ "name": "ledger_valuation_ledger_entry_id_user_id_pk",
+ "columns": [
+ "ledger_entry_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification_outbox": {
+ "name": "notification_outbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "event_key": {
+ "name": "event_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "installation_id": {
+ "name": "installation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recipient_user_id": {
+ "name": "recipient_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "delivery_mode": {
+ "name": "delivery_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'immediate'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "processing_started_at": {
+ "name": "processing_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notification_outbox_event_unique": {
+ "name": "notification_outbox_event_unique",
+ "columns": [
+ {
+ "expression": "event_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notification_outbox_pending_idx": {
+ "name": "notification_outbox_pending_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "available_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notification_outbox_installation_idx": {
+ "name": "notification_outbox_installation_idx",
+ "columns": [
+ {
+ "expression": "installation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notification_outbox_installation_id_push_installation_id_fk": {
+ "name": "notification_outbox_installation_id_push_installation_id_fk",
+ "tableFrom": "notification_outbox",
+ "tableTo": "push_installation",
+ "columnsFrom": [
+ "installation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_outbox_recipient_user_id_user_id_fk": {
+ "name": "notification_outbox_recipient_user_id_user_id_fk",
+ "tableFrom": "notification_outbox",
+ "tableTo": "user",
+ "columnsFrom": [
+ "recipient_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.profile": {
+ "name": "profile",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "home_currency": {
+ "name": "home_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'EUR'"
+ },
+ "notifications_enabled": {
+ "name": "notifications_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notification_only_when_involved": {
+ "name": "notification_only_when_involved",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "summarize_notification_bursts": {
+ "name": "summarize_notification_bursts",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "onboarded_at": {
+ "name": "onboarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "profile_user_id_user_id_fk": {
+ "name": "profile_user_id_user_id_fk",
+ "tableFrom": "profile",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_installation": {
+ "name": "push_installation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environment": {
+ "name": "environment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "disabled_at": {
+ "name": "disabled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "push_installation_token_unique": {
+ "name": "push_installation_token_unique",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "push_installation_user_idx": {
+ "name": "push_installation_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "push_installation_delivery_idx": {
+ "name": "push_installation_delivery_idx",
+ "columns": [
+ {
+ "expression": "environment",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "disabled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_installation_user_id_user_id_fk": {
+ "name": "push_installation_user_id_user_id_fk",
+ "tableFrom": "push_installation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_snapshot": {
+ "name": "rate_snapshot",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "expense_id": {
+ "name": "expense_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settlement_id": {
+ "name": "settlement_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base": {
+ "name": "base",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quote": {
+ "name": "quote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rate": {
+ "name": "rate",
+ "type": "numeric(30, 15)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_date": {
+ "name": "provider_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "rate_expense_idx": {
+ "name": "rate_expense_idx",
+ "columns": [
+ {
+ "expression": "expense_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rate_settlement_idx": {
+ "name": "rate_settlement_idx",
+ "columns": [
+ {
+ "expression": "settlement_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rate_snapshot_expense_id_expense_id_fk": {
+ "name": "rate_snapshot_expense_id_expense_id_fk",
+ "tableFrom": "rate_snapshot",
+ "tableTo": "expense",
+ "columnsFrom": [
+ "expense_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "rate_snapshot_settlement_id_settlement_id_fk": {
+ "name": "rate_snapshot_settlement_id_settlement_id_fk",
+ "tableFrom": "rate_snapshot",
+ "tableTo": "settlement",
+ "columnsFrom": [
+ "settlement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "session_user_idx": {
+ "name": "session_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settlement": {
+ "name": "settlement",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "context_type": {
+ "name": "context_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "friendship_id": {
+ "name": "friendship_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_user_id": {
+ "name": "to_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "source_currency": {
+ "name": "source_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_amount_minor": {
+ "name": "source_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_currency": {
+ "name": "canonical_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_amount_minor": {
+ "name": "canonical_amount_minor",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_mutation_id": {
+ "name": "client_mutation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "settlement_idempotency_unique": {
+ "name": "settlement_idempotency_unique",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "client_mutation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settlement_group_idx": {
+ "name": "settlement_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settlement_friendship_idx": {
+ "name": "settlement_friendship_idx",
+ "columns": [
+ {
+ "expression": "friendship_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settlement_active_group_idx": {
+ "name": "settlement_active_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settlement_active_friendship_idx": {
+ "name": "settlement_active_friendship_idx",
+ "columns": [
+ {
+ "expression": "friendship_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "settlement_group_id_expense_group_id_fk": {
+ "name": "settlement_group_id_expense_group_id_fk",
+ "tableFrom": "settlement",
+ "tableTo": "expense_group",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "settlement_friendship_id_friendship_id_fk": {
+ "name": "settlement_friendship_id_friendship_id_fk",
+ "tableFrom": "settlement",
+ "tableTo": "friendship",
+ "columnsFrom": [
+ "friendship_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "settlement_created_by_user_id_fk": {
+ "name": "settlement_created_by_user_id_fk",
+ "tableFrom": "settlement",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "settlement_from_user_id_user_id_fk": {
+ "name": "settlement_from_user_id_user_id_fk",
+ "tableFrom": "settlement",
+ "tableTo": "user",
+ "columnsFrom": [
+ "from_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "settlement_to_user_id_user_id_fk": {
+ "name": "settlement_to_user_id_user_id_fk",
+ "tableFrom": "settlement",
+ "tableTo": "user",
+ "columnsFrom": [
+ "to_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 5e64110..379376c 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -92,6 +92,13 @@
"when": 1788446448403,
"tag": "0012_flashy_jasper_sitwell",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1790243657251,
+ "tag": "0013_gray_thunderbolts",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 333cb35..96b5135 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -79,7 +79,7 @@ export const accounts = pgTable(
"account",
{
id: text("id").primaryKey(),
- issuer: text("issuer").notNull(),
+ issuer: text("issuer"),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
@@ -100,7 +100,7 @@ export const accounts = pgTable(
},
(table) => [
index("account_user_idx").on(table.userId),
- uniqueIndex("account_issuer_unique").on(table.issuer, table.accountId),
+ uniqueIndex("account_provider_unique").on(table.providerId, table.accountId),
],
);
From c164c4ff17d2ffb76e5f30cdee5754e38b41c86c Mon Sep 17 00:00:00 2001
From: Florian2807 <84639717+Florian2807@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:34:52 +0200
Subject: [PATCH 2/3] Adapt mobile navigation and scrolling for iPad
---
apps/mobile/app.config.ts | 4 +-
.../src/__tests__/friend-detail.test.tsx | 53 ++++++++++--
apps/mobile/src/app/(tabs)/friends/[id].tsx | 39 +++++++--
.../mobile/src/app/(tabs)/profile/_layout.tsx | 4 +-
apps/mobile/src/app/_layout.tsx | 5 +-
.../src/components/group-statistics.tsx | 5 +-
apps/mobile/src/components/ui.test.tsx | 48 ++++++++++-
apps/mobile/src/components/ui.tsx | 84 ++++++++++++++-----
8 files changed, 200 insertions(+), 42 deletions(-)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 7261b3d..2d920dc 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -17,7 +17,7 @@ const config: ExpoConfig = {
slug: "splidly",
scheme: "splidly",
version: "1.0.0",
- orientation: "portrait",
+ orientation: "default",
userInterfaceStyle: "automatic",
experiments: { typedRoutes: true },
extra: {
@@ -54,7 +54,7 @@ const config: ExpoConfig = {
ios: {
icon: "./assets/icons/splidly.icon",
bundleIdentifier: buildConfig.iosBundleIdentifier,
- supportsTablet: false,
+ supportsTablet: true,
usesAppleSignIn: true,
associatedDomains: [`applinks:${host}`],
privacyManifests: {
diff --git a/apps/mobile/src/__tests__/friend-detail.test.tsx b/apps/mobile/src/__tests__/friend-detail.test.tsx
index 32ceeb6..76d81fe 100644
--- a/apps/mobile/src/__tests__/friend-detail.test.tsx
+++ b/apps/mobile/src/__tests__/friend-detail.test.tsx
@@ -30,11 +30,20 @@ let mockFriendSettlements: Array<{
};
}> = [];
-jest.mock("expo-router", () => ({
- router: { push: jest.fn() },
- useLocalSearchParams: () => ({ id: "friendship-1" }),
- Stack: { Screen: () => null },
-}));
+jest.mock("expo-router", () => {
+ const { Text } = require("react-native") as typeof import("react-native");
+ return {
+ router: { push: jest.fn() },
+ useLocalSearchParams: () => ({ id: "friendship-1" }),
+ Stack: {
+ Screen: ({ options }: { options?: { title?: string } }) => (
+
+ {options?.title ?? ""}
+
+ ),
+ },
+ };
+});
jest.mock("../lib/trpc", () => ({
api: {
@@ -89,6 +98,40 @@ describe("FriendDetailScreen", () => {
jest.clearAllMocks();
});
+ it("shows the navigation title only after the profile name scrolls away", async () => {
+ const view = await render(
+
+
+ ,
+ );
+ expect(view.getByTestId("friend-navigation-title").props.children).toBe("");
+ await fireEvent(view.getByTestId("friend-identity-header"), "layout", {
+ nativeEvent: { layout: { x: 0, y: 0, width: 300, height: 120 } },
+ });
+ const [scrollView] = view.container.queryAll(
+ (instance) => instance.props.contentInsetAdjustmentBehavior === "automatic",
+ );
+ if (!scrollView) throw new Error("Friend ScrollView was not rendered");
+ const scroll = async (y: number) =>
+ fireEvent.scroll(scrollView, {
+ nativeEvent: {
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
+ contentOffset: { x: 0, y },
+ contentSize: { width: 300, height: 900 },
+ layoutMeasurement: { width: 300, height: 700 },
+ zoomScale: 1,
+ },
+ });
+ await scroll(120);
+ expect(view.getByTestId("friend-navigation-title").props.children).toBe(
+ "Demo User",
+ );
+ await scroll(0);
+ expect(view.getByTestId("friend-navigation-title").props.children).toBe("");
+ });
+
it("allows a payment even when there are no open balances", async () => {
const view = await render(
();
const theme = useTheme();
+ const headerHeight = use(HeaderHeightContext) ?? 0;
+ const [compactTitleVisible, setCompactTitleVisible] = useState(
+ process.env.EXPO_OS !== "ios",
+ );
+ const compactTitleVisibleRef = useRef(process.env.EXPO_OS !== "ios");
+ const identityBottomRef = useRef(0);
const detail = api.friends.detail.useQuery({ friendshipId: id });
const list = api.friends.list.useQuery();
const profile = api.profile.me.useQuery();
@@ -68,8 +75,28 @@ export default function FriendDetailScreen() {
const activityGroups = groupActivityByDate(activity);
return (
<>
-
-
+ {
+ if (process.env.EXPO_OS !== "ios") return;
+ const visibleContentTop =
+ event.nativeEvent.contentOffset.y +
+ Math.max(event.nativeEvent.contentInset.top, headerHeight);
+ const nextVisible =
+ identityBottomRef.current > 0 &&
+ visibleContentTop >= identityBottomRef.current;
+ if (nextVisible === compactTitleVisibleRef.current) return;
+ compactTitleVisibleRef.current = nextVisible;
+ setCompactTitleVisible(nextVisible);
+ }}
+ >
+ {
+ const { y, height } = event.nativeEvent.layout;
+ identityBottomRef.current = y + height;
+ }}
+ style={{ alignItems: "center", gap: 10, paddingVertical: 8 }}
+ >
-
- Direct and group balances stay separated so every amount remains
- traceable to its original ledger.
-
{summary?.balances.length ? (
{summary.balances.map((balance, index) => (
@@ -231,7 +254,7 @@ export default function FriendDetailScreen() {
/>
-
+
>
);
}
diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx
index 230810e..a0d0b68 100644
--- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx
+++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx
@@ -1,4 +1,5 @@
import { Stack } from "expo-router";
+import { Platform } from "react-native";
import {
formSheetOptions,
inlineLargeTitleOptions,
@@ -8,6 +9,7 @@ import { useTheme } from "../../../theme";
export default function ProfileStackLayout() {
const theme = useTheme();
+ const tablet = process.env.EXPO_OS === "ios" && "isPad" in Platform && Platform.isPad;
return (
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 658ebe0..0204a23 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -6,7 +6,7 @@ import {
ThemeProvider,
} from "expo-router";
import { StatusBar } from "expo-status-bar";
-import { useColorScheme } from "react-native";
+import { Platform, useColorScheme } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import {
expenseDetailSheetOptions,
@@ -24,6 +24,7 @@ import { NotificationCoordinator } from "../components/notification-coordinator"
function Navigation() {
const theme = useTheme();
const colorScheme = useColorScheme();
+ const ipad = process.env.EXPO_OS === "ios" && "isPad" in Platform && Platform.isPad;
return (
diff --git a/apps/mobile/src/components/group-statistics.tsx b/apps/mobile/src/components/group-statistics.tsx
index fa5ba70..b3078a7 100644
--- a/apps/mobile/src/components/group-statistics.tsx
+++ b/apps/mobile/src/components/group-statistics.tsx
@@ -564,7 +564,10 @@ function TimelineChart({
const highlighted = selected ?? latest;
const columnWidth = Math.max(
44,
- Math.floor((width - 80) / Math.max(1, Math.min(chartData.length, 8))),
+ Math.floor(
+ ((process.env.EXPO_OS === "ios" ? Math.min(width, 800) : width) - 80) /
+ Math.max(1, Math.min(chartData.length, 8)),
+ ),
);
const timelineKey = `${rangeKey}:${latest?.period ?? "empty"}`;
const scrollToLatest = useCallback(() => {
diff --git a/apps/mobile/src/components/ui.test.tsx b/apps/mobile/src/components/ui.test.tsx
index 741a878..2d9b899 100644
--- a/apps/mobile/src/components/ui.test.tsx
+++ b/apps/mobile/src/components/ui.test.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { act, fireEvent, render } from "@testing-library/react-native";
import { HeaderHeightContext } from "expo-router/build/react-navigation/elements/Header/HeaderHeightContext";
-import { Keyboard, StyleSheet, Text } from "react-native";
+import { Keyboard, StyleSheet, Text, type ScrollView, type TextInput } from "react-native";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import {
CollectionScreen,
@@ -9,6 +9,7 @@ import {
ErrorState,
Field,
Screen,
+ useKeyboardFocusScroll,
} from "./ui";
import { SERVER_UNAVAILABLE_MESSAGE } from "../lib/network";
@@ -19,6 +20,51 @@ function ControlledField() {
);
}
+describe("useKeyboardFocusScroll", () => {
+ it("scrolls only when the focused input is covered by the keyboard", async () => {
+ const scrollTo = jest.fn();
+ const inputY = { current: 100 };
+ const input = {
+ measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) =>
+ callback(0, inputY.current, 100, 34),
+ } as unknown as TextInput;
+ const scrollView = {
+ getNativeScrollRef: () => ({
+ measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) =>
+ callback(0, 0, 400, 700),
+ }),
+ scrollTo,
+ } as unknown as ScrollView;
+ const scrollRef = { current: scrollView };
+ let controls!: ReturnType;
+ function Probe() {
+ controls = useKeyboardFocusScroll(scrollRef, 104);
+ return null;
+ }
+ const metrics = jest.spyOn(Keyboard, "metrics").mockReturnValue({
+ screenX: 0,
+ screenY: 500,
+ width: 400,
+ height: 300,
+ });
+ try {
+ await render();
+ await act(async () => {
+ controls.focusInput(input);
+ controls.revealFocusedInput();
+ });
+ expect(scrollTo).not.toHaveBeenCalled();
+
+ inputY.current = 450;
+ controls.onScroll({ nativeEvent: { contentOffset: { y: 20 } } } as Parameters[0]);
+ await act(async () => controls.revealFocusedInput());
+ expect(scrollTo).toHaveBeenCalledWith({ y: 108, animated: true });
+ } finally {
+ metrics.mockRestore();
+ }
+ });
+});
+
describe("EmptyState", () => {
it("renders an accessible title and explanation", async () => {
const view = await render(
diff --git a/apps/mobile/src/components/ui.tsx b/apps/mobile/src/components/ui.tsx
index dfcef22..c3aff0a 100644
--- a/apps/mobile/src/components/ui.tsx
+++ b/apps/mobile/src/components/ui.tsx
@@ -15,7 +15,6 @@ import {
} from "react";
import {
ActivityIndicator,
- findNodeHandle,
Keyboard,
Pressable,
RefreshControl,
@@ -24,6 +23,7 @@ import {
Text,
TextInput,
useColorScheme,
+ useWindowDimensions,
View,
type AccessibilityState,
type ColorValue,
@@ -159,53 +159,74 @@ export function useKeyboardFocusScroll(
additionalOffset = spacing.md,
) {
const focusedInputRef = useRef(null);
+ const scrollYRef = useRef(0);
+ const keyboardTopRef = useRef(null);
const [keyboardClearance, setKeyboardClearance] = useState(0);
const revealFocusedInput = useCallback(() => {
- const inputHandle = findNodeHandle(focusedInputRef.current);
- if (inputHandle === null) return;
+ const input = focusedInputRef.current;
+ const keyboardTop = keyboardTopRef.current;
+ if (!input || keyboardTop === null) return;
const scrollView = scrollViewRef.current;
if (!scrollView) return;
- const reveal = (viewportScreenY: number) => {
- scrollView.scrollResponderScrollNativeHandleToKeyboard(
- inputHandle,
- additionalOffset + viewportScreenY,
- true,
- );
- };
const nativeScrollView = scrollView.getNativeScrollRef();
- if (!nativeScrollView) {
- reveal(0);
- return;
- }
- nativeScrollView.measureInWindow((_x, y) => reveal(y));
+ if (!nativeScrollView) return;
+ nativeScrollView.measureInWindow((_x, viewportY, _width, viewportHeight) => {
+ input.measureInWindow((_inputX, inputY, _inputWidth, inputHeight) => {
+ const visibleTop = viewportY + spacing.md;
+ const visibleBottom = Math.min(
+ viewportY + viewportHeight,
+ keyboardTop,
+ ) - additionalOffset;
+ const inputBottom = inputY + inputHeight;
+ const delta =
+ inputBottom > visibleBottom
+ ? inputBottom - visibleBottom
+ : inputY < visibleTop
+ ? inputY - visibleTop
+ : 0;
+ if (Math.abs(delta) <= 1) return;
+ scrollView.scrollTo({
+ y: Math.max(0, scrollYRef.current + delta),
+ animated: true,
+ });
+ });
+ });
}, [additionalOffset, scrollViewRef]);
useEffect(() => {
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
if (!focusedInputRef.current) return;
+ keyboardTopRef.current = event.endCoordinates.screenY;
+ setKeyboardClearance(event.endCoordinates.height);
+ requestAnimationFrame(revealFocusedInput);
+ });
+ const changed = Keyboard.addListener("keyboardDidChangeFrame", (event) => {
+ if (!focusedInputRef.current) return;
+ keyboardTopRef.current = event.endCoordinates.screenY;
setKeyboardClearance(event.endCoordinates.height);
requestAnimationFrame(revealFocusedInput);
});
const hidden = Keyboard.addListener("keyboardDidHide", () => {
+ keyboardTopRef.current = null;
setKeyboardClearance(0);
});
return () => {
shown.remove();
+ changed.remove();
hidden.remove();
};
}, [revealFocusedInput]);
- useEffect(() => {
- if (keyboardClearance <= 0) return;
- requestAnimationFrame(revealFocusedInput);
- }, [keyboardClearance, revealFocusedInput]);
-
const focusInput = useCallback(
(input: TextInput | null) => {
focusedInputRef.current = input;
- setKeyboardClearance(Keyboard.metrics()?.height ?? 0);
- requestAnimationFrame(revealFocusedInput);
+ const metrics = Keyboard.metrics();
+ if (metrics?.height) {
+ keyboardTopRef.current = metrics.screenY;
+ setKeyboardClearance(metrics.height);
+ requestAnimationFrame(revealFocusedInput);
+ }
},
[revealFocusedInput],
);
@@ -219,6 +240,9 @@ export function useKeyboardFocusScroll(
focusInput,
blurInput,
revealFocusedInput,
+ onScroll: (event: Parameters>[0]) => {
+ scrollYRef.current = event.nativeEvent.contentOffset.y;
+ },
};
}
@@ -255,6 +279,11 @@ export function Screen({
onScroll?: ScrollViewProps["onScroll"];
}>) {
const theme = useTheme();
+ const { width: windowWidth } = useWindowDimensions();
+ const tabletContentStyle =
+ process.env.EXPO_OS === "ios" && windowWidth >= 700
+ ? styles.tabletContent
+ : null;
const insets = useSafeAreaInsets();
const keyboardBottomClearance = useKeyboardBottomClearance();
const backgroundColor =
@@ -282,6 +311,7 @@ export function Screen({
? { paddingBottom: bottomOverlayHeight + insets.bottom }
: null,
contentContainerStyle,
+ tabletContentStyle,
]}
>
{children}
@@ -303,6 +333,7 @@ export function Screen({
styles.screenContent,
fillStyle,
contentContainerStyle,
+ tabletContentStyle,
]}
contentInsetAdjustmentBehavior="automatic"
keyboardDismissMode="interactive"
@@ -369,6 +400,7 @@ export function CollectionScreen({
onRefresh?: () => void;
}>) {
const theme = useTheme();
+ const { width: windowWidth } = useWindowDimensions();
const { fillStyle, onLayout, onContentSizeChange } = useScrollViewportFill({
underlapsHeader: true,
});
@@ -379,6 +411,9 @@ export function CollectionScreen({
styles.collectionContent,
fillStyle,
isEmpty ? styles.emptyCollectionContent : null,
+ process.env.EXPO_OS === "ios" && windowWidth >= 700
+ ? styles.tabletContent
+ : null,
]}
contentInsetAdjustmentBehavior="automatic"
alwaysBounceVertical={!isEmpty || Boolean(onRefresh)}
@@ -1055,6 +1090,11 @@ export function SelectionPill({
const styles = StyleSheet.create({
screen: { flex: 1 },
+ tabletContent: {
+ width: "100%",
+ maxWidth: 800,
+ alignSelf: "center",
+ },
collectionContent: {
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
From c4f5960cc7dd858da7104c3ffe1893cb347eee69 Mon Sep 17 00:00:00 2001
From: Florian2807 <84639717+Florian2807@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:34:56 +0200
Subject: [PATCH 3/3] Refine expense and payment form layouts
---
apps/mobile/src/__tests__/settlement.test.tsx | 17 +++++++---
apps/mobile/src/app/settlement/new.tsx | 6 ++--
.../allocation-floating-summary.ios.tsx | 17 ++++++----
.../allocation-floating-summary.tsx | 6 ++--
.../src/components/expense-allocation-ui.tsx | 7 ++--
.../src/components/expense-composer-ui.tsx | 6 ++--
.../src/components/expense-editor.test.tsx | 4 +--
apps/mobile/src/components/expense-editor.tsx | 8 ++---
.../components/expense-item-split-editor.tsx | 5 +--
.../src/components/expense-payment-editor.tsx | 5 +--
.../components/expense-split-editor.test.tsx | 9 ++++-
.../src/components/expense-split-editor.tsx | 20 +++++++----
.../src/components/floating-controls.test.tsx | 34 +++++++++++++++++++
.../src/components/settlement-composer.tsx | 29 +++++++++++-----
14 files changed, 121 insertions(+), 52 deletions(-)
create mode 100644 apps/mobile/src/components/floating-controls.test.tsx
diff --git a/apps/mobile/src/__tests__/settlement.test.tsx b/apps/mobile/src/__tests__/settlement.test.tsx
index d2a746d..a5dac10 100644
--- a/apps/mobile/src/__tests__/settlement.test.tsx
+++ b/apps/mobile/src/__tests__/settlement.test.tsx
@@ -257,6 +257,17 @@ describe("NewSettlementScreen group context", () => {
expect(view.getByLabelText("Paid by: Alex")).toBeTruthy();
expect(view.getByLabelText("Paid to: You")).toBeTruthy();
expect(view.getByDisplayValue("12.34")).toBeTruthy();
+ await fireEvent(view.getByTestId("settlement-paid-by-slot"), "layout", {
+ nativeEvent: { layout: { x: 0, y: 0, width: 220, height: 110 } },
+ });
+ expect(
+ StyleSheet.flatten(view.getByTestId("settlement-paid-by").props.style)
+ .width,
+ ).toBe(220);
+ expect(
+ StyleSheet.flatten(view.getByTestId("settlement-paid-to").props.style)
+ .width,
+ ).toBe(220);
expect(
StyleSheet.flatten(view.getByTestId("settlement-paid-by-slot").props.style)
.flex,
@@ -345,7 +356,7 @@ describe("NewSettlementScreen group context", () => {
).toEqual({ disabled: true });
});
- it("keeps a growing note tied to the focused-input scroll behavior", async () => {
+ it("does not scroll the sheet after every note size change", async () => {
const view = await render(
{
await fireEvent.press(view.getByText("Add a note"));
- expect(view.getByLabelText("Notes").props.onContentSizeChange).toEqual(
- expect.any(Function),
- );
+ expect(view.getByLabelText("Notes").props.onContentSizeChange).toBeUndefined();
});
it("can always be cancelled from the native sheet toolbar", async () => {
diff --git a/apps/mobile/src/app/settlement/new.tsx b/apps/mobile/src/app/settlement/new.tsx
index b94d022..cd183dc 100644
--- a/apps/mobile/src/app/settlement/new.tsx
+++ b/apps/mobile/src/app/settlement/new.tsx
@@ -90,7 +90,7 @@ export default function NewSettlementScreen() {
keyboardClearance,
focusInput: focusBottomInput,
blurInput: blurBottomInput,
- revealFocusedInput: revealBottomInput,
+ onScroll: onBottomScroll,
} = useKeyboardFocusScroll(screenRef, settlementOverlayHeight + 16);
let signedCanonicalMinor: bigint | undefined;
@@ -649,9 +649,6 @@ export default function NewSettlementScreen() {
multiline
onFocus={() => focusBottomInput(notesInputRef.current)}
onBlur={() => blurBottomInput(notesInputRef.current)}
- onContentSizeChange={() =>
- requestAnimationFrame(revealBottomInput)
- }
textAlignVertical="top"
style={{
color: theme.text,
@@ -723,6 +720,7 @@ export default function NewSettlementScreen() {
<>
{
+ const nextWidth = event.nativeEvent.layout.width;
+ setWidth((current) => current === nextWidth ? current : nextWidth);
+ }}
style={{
- width,
+ width: "100%",
+ maxWidth: 768,
height: 76,
overflow: "hidden",
borderRadius: 24,
@@ -72,7 +77,7 @@ export function AllocationFloatingSummary({
: "0 10px 28px rgba(0, 0, 0, 0.18)",
}}
>
- 0 ?
@@ -115,7 +120,7 @@ export function AllocationFloatingSummary({
]}
/>
-
+ : null}
);
}
diff --git a/apps/mobile/src/components/allocation-floating-summary.tsx b/apps/mobile/src/components/allocation-floating-summary.tsx
index ed612d0..6609c7b 100644
--- a/apps/mobile/src/components/allocation-floating-summary.tsx
+++ b/apps/mobile/src/components/allocation-floating-summary.tsx
@@ -1,4 +1,4 @@
-import { Text, useWindowDimensions, View } from "react-native";
+import { Text, View } from "react-native";
import { useTheme } from "../theme";
/**
@@ -15,7 +15,6 @@ export function AllocationFloatingSummary({
complete: boolean;
}) {
const theme = useTheme();
- const { width: windowWidth } = useWindowDimensions();
const progressWidth =
`${Math.max(0, Math.min(1, progress)) * 100}%` as const;
@@ -33,7 +32,8 @@ export function AllocationFloatingSummary({
now: Math.round(Math.max(0, Math.min(1, progress)) * 100),
}}
style={{
- width: windowWidth - 32,
+ width: "100%",
+ maxWidth: 768,
height: 76,
paddingHorizontal: 16,
paddingVertical: 13,
diff --git a/apps/mobile/src/components/expense-allocation-ui.tsx b/apps/mobile/src/components/expense-allocation-ui.tsx
index 25aa54c..b68fa56 100644
--- a/apps/mobile/src/components/expense-allocation-ui.tsx
+++ b/apps/mobile/src/components/expense-allocation-ui.tsx
@@ -175,7 +175,9 @@ export function InlineAmountInput({
const digitCount = value.replace(/\D/g, "").length;
const fontSize = digitCount > 9 ? 13 : digitCount > 6 ? 14 : 15;
return (
- inputRef.current?.focus()}
style={{
width,
minHeight: 34,
@@ -196,7 +198,6 @@ export function InlineAmountInput({
onFocus={() => onFocus?.(inputRef.current)}
onBlur={() => onBlur?.(inputRef.current)}
keyboardType={keyboardType}
- returnKeyType="done"
selectTextOnFocus
numberOfLines={1}
placeholder={placeholder}
@@ -221,7 +222,7 @@ export function InlineAmountInput({
>
{suffix}
-
+
);
}
diff --git a/apps/mobile/src/components/expense-composer-ui.tsx b/apps/mobile/src/components/expense-composer-ui.tsx
index b03c42a..53fc1b2 100644
--- a/apps/mobile/src/components/expense-composer-ui.tsx
+++ b/apps/mobile/src/components/expense-composer-ui.tsx
@@ -3,7 +3,6 @@ import {
Pressable,
Text,
TextInput,
- useWindowDimensions,
View,
type ColorValue,
} from "react-native";
@@ -339,12 +338,11 @@ export function ExpenseSaveControl({
foregroundColor?: ColorValue;
}) {
const theme = useTheme();
- const { width } = useWindowDimensions();
-
return (
{
await fireEvent.press(view.getByText("Add a note"));
- expect(view.getByLabelText("Notes").props.onContentSizeChange).toEqual(
- expect.any(Function),
- );
+ expect(view.getByLabelText("Notes").props.onContentSizeChange).toBeUndefined();
expect(view.getByTestId("screen-bottom-overlay")).toBeTruthy();
expect(view.getByText("Save expense")).toBeTruthy();
expect(view.getAllByText("Add expense")).toHaveLength(2);
diff --git a/apps/mobile/src/components/expense-editor.tsx b/apps/mobile/src/components/expense-editor.tsx
index b11a362..0cd3ee8 100644
--- a/apps/mobile/src/components/expense-editor.tsx
+++ b/apps/mobile/src/components/expense-editor.tsx
@@ -232,7 +232,7 @@ export function ExpenseEditor({
keyboardClearance,
focusInput: focusBottomInput,
blurInput: blurBottomInput,
- revealFocusedInput: revealBottomInput,
+ onScroll: onBottomScroll,
} = useKeyboardFocusScroll(screenRef, expenseOverlayHeight + 16);
const detectedIconKey = useMemo(
() => detectExpenseIconKey(description),
@@ -736,6 +736,7 @@ export function ExpenseEditor({
<>
focusBottomInput(notesInputRef.current)}
onBlur={() => blurBottomInput(notesInputRef.current)}
- onContentSizeChange={() =>
- requestAnimationFrame(revealBottomInput)
- }
textAlignVertical="top"
style={{
color: theme.text,
diff --git a/apps/mobile/src/components/expense-item-split-editor.tsx b/apps/mobile/src/components/expense-item-split-editor.tsx
index 2e0a314..5f79c86 100644
--- a/apps/mobile/src/components/expense-item-split-editor.tsx
+++ b/apps/mobile/src/components/expense-item-split-editor.tsx
@@ -66,7 +66,7 @@ export function ExpenseItemSplitEditor() {
request?.item,
);
const screenRef = useRef(null);
- const { keyboardClearance, focusInput, blurInput } =
+ const { keyboardClearance, focusInput, blurInput, onScroll } =
useKeyboardFocusScroll(screenRef, 104);
useEffect(() => {
@@ -150,6 +150,7 @@ export function ExpenseItemSplitEditor() {
- {process.env.EXPO_OS === "ios" ? "Done" : null}
+ {process.env.EXPO_OS === "ios" ? "Save" : null}
>
diff --git a/apps/mobile/src/components/expense-payment-editor.tsx b/apps/mobile/src/components/expense-payment-editor.tsx
index 73fcecd..9a5bda3 100644
--- a/apps/mobile/src/components/expense-payment-editor.tsx
+++ b/apps/mobile/src/components/expense-payment-editor.tsx
@@ -37,7 +37,7 @@ export function ExpensePaymentEditor() {
request?.draft,
);
const screenRef = useRef(null);
- const { keyboardClearance, focusInput, blurInput } =
+ const { keyboardClearance, focusInput, blurInput, onScroll } =
useKeyboardFocusScroll(screenRef, 104);
useEffect(() => {
@@ -107,6 +107,7 @@ export function ExpensePaymentEditor() {
<>
- {process.env.EXPO_OS === "ios" ? "Done" : null}
+ {process.env.EXPO_OS === "ios" ? "Save" : null}
>
diff --git a/apps/mobile/src/components/expense-split-editor.test.tsx b/apps/mobile/src/components/expense-split-editor.test.tsx
index e97649e..07dbdb8 100644
--- a/apps/mobile/src/components/expense-split-editor.test.tsx
+++ b/apps/mobile/src/components/expense-split-editor.test.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render } from "@testing-library/react-native";
import type { ReactNode } from "react";
-import { Text, Pressable } from "react-native";
+import { Text, Pressable, StyleSheet } from "react-native";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import {
createExpenseSplitDraft,
@@ -120,6 +120,13 @@ describe("ExpenseSplitEditor", () => {
wrapper: Wrapper,
});
await fireEvent.press(view.getByText("Open editor"));
+ await fireEvent(view.getByTestId("split-method-section"), "layout", {
+ nativeEvent: { layout: { x: 0, y: 0, width: 720, height: 120 } },
+ });
+ expect(
+ StyleSheet.flatten(view.getByTestId("split-method-picker").props.style)
+ .width,
+ ).toBe(720);
await fireEvent(view.getByTestId("split-method-picker"), "pressAction", {
nativeEvent: { event: "percentage" },
});
diff --git a/apps/mobile/src/components/expense-split-editor.tsx b/apps/mobile/src/components/expense-split-editor.tsx
index e3018e0..0077349 100644
--- a/apps/mobile/src/components/expense-split-editor.tsx
+++ b/apps/mobile/src/components/expense-split-editor.tsx
@@ -58,13 +58,14 @@ export function ExpenseSplitEditor() {
const [draft, setDraft] = useState(
request?.draft,
);
+ const [methodWidth, setMethodWidth] = useState(0);
const screenRef = useRef(null);
const itemInputRefs = useRef(new Map());
const {
keyboardClearance,
focusInput,
blurInput,
- revealFocusedInput,
+ onScroll,
} = useKeyboardFocusScroll(screenRef, 104);
useEffect(() => {
if (request) setDraft(request.draft);
@@ -267,6 +268,7 @@ export function ExpenseSplitEditor() {
<>
-
+ {
+ const width = event.nativeEvent.layout.width;
+ setMethodWidth((current) => current === width ? current : width);
+ }}
+ >
0 ? { width: methodWidth } : { width: "100%" }}
onPressAction={({ nativeEvent }) => {
const nextMode = nativeEvent.event;
if (!isExpenseSplitMode(nextMode)) return;
@@ -296,6 +306,7 @@ export function ExpenseSplitEditor() {
0 ? methodWidth : "100%",
minHeight: 68,
paddingHorizontal: 16,
paddingVertical: 12,
@@ -590,9 +601,6 @@ export function ExpenseSplitEditor() {
itemInputRefs.current.get(`${item.id}:name`) ?? null,
)
}
- onContentSizeChange={() =>
- requestAnimationFrame(revealFocusedInput)
- }
placeholder="Item name (optional)"
placeholderTextColor={theme.subtle}
selectionColor={theme.primary}
@@ -908,7 +916,7 @@ export function ExpenseSplitEditor() {
disabled={!status.valid}
onPress={done}
>
- {process.env.EXPO_OS === "ios" ? "Done" : null}
+ {process.env.EXPO_OS === "ios" ? "Save" : null}
>
diff --git a/apps/mobile/src/components/floating-controls.test.tsx b/apps/mobile/src/components/floating-controls.test.tsx
new file mode 100644
index 0000000..5b1a718
--- /dev/null
+++ b/apps/mobile/src/components/floating-controls.test.tsx
@@ -0,0 +1,34 @@
+import { render } from "@testing-library/react-native";
+import { StyleSheet } from "react-native";
+import { ExpenseSaveControl } from "./expense-composer-ui";
+import { SettlementSaveControl } from "./settlement-composer";
+import { AllocationFloatingSummary } from "./allocation-floating-summary";
+
+describe("floating controls in form sheets", () => {
+ it("sizes save controls to the sheet instead of the device window", async () => {
+ for (const Control of [ExpenseSaveControl, SettlementSaveControl]) {
+ const view = await render(
+ ,
+ );
+ const [container] = view.container.queryAll((instance) => {
+ const style = StyleSheet.flatten(instance.props.style);
+ return style?.maxWidth === 768;
+ });
+ const style = StyleSheet.flatten(container?.props.style);
+ expect(style.width).toBe("100%");
+ expect(style.maxWidth).toBe(768);
+ await view.unmount();
+ }
+ });
+
+ it("keeps the allocation summary inside the sheet", async () => {
+ const view = await render(
+ ,
+ );
+ const style = StyleSheet.flatten(
+ view.getByTestId("allocation-floating-summary").props.style,
+ );
+ expect(style.width).toBe("100%");
+ expect(style.maxWidth).toBe(768);
+ });
+});
diff --git a/apps/mobile/src/components/settlement-composer.tsx b/apps/mobile/src/components/settlement-composer.tsx
index db72709..b2d0b0d 100644
--- a/apps/mobile/src/components/settlement-composer.tsx
+++ b/apps/mobile/src/components/settlement-composer.tsx
@@ -3,12 +3,11 @@ import {
MenuView,
type MenuAction,
} from "@expo/ui/community/menu";
-import { useRef, type ReactNode } from "react";
+import { useRef, useState, type ReactNode } from "react";
import {
Pressable,
Text,
TextInput,
- useWindowDimensions,
View,
} from "react-native";
import { useTheme } from "../theme";
@@ -30,6 +29,7 @@ function PartyChoice({
onValueChange,
disabled,
testID,
+ width,
}: {
label: string;
value: SettlementMember | undefined;
@@ -38,6 +38,7 @@ function PartyChoice({
onValueChange: (userId: string) => void;
disabled: boolean;
testID: string;
+ width: number;
}) {
const theme = useTheme();
const name = value
@@ -51,7 +52,7 @@ function PartyChoice({
accessibilityRole={disabled ? undefined : "button"}
accessibilityLabel={`${label}: ${name}`}
style={{
- width: "100%",
+ width: width > 0 ? width : "100%",
minWidth: 0,
alignItems: "center",
gap: 7,
@@ -94,7 +95,7 @@ function PartyChoice({
{name}
@@ -119,7 +120,7 @@ function PartyChoice({
title={label}
actions={actions}
testID={testID}
- style={{ width: "100%" }}
+ style={width > 0 ? { width } : { width: "100%" }}
onPressAction={({ nativeEvent }) => onValueChange(nativeEvent.event)}
>
{content}
@@ -145,6 +146,7 @@ export function SettlementDirectionCard({
locked: boolean;
}) {
const theme = useTheme();
+ const [slotWidth, setSlotWidth] = useState(0);
return (
-
+ {
+ const width = event.nativeEvent.layout.width;
+ setSlotWidth((current) => current === width ? current : width);
+ }}
+ >
-
+
@@ -326,11 +337,11 @@ export function SettlementSaveControl({
disabled: boolean;
}) {
const theme = useTheme();
- const { width } = useWindowDimensions();
return (