Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:49:45 -07:00
commit ac0b82e6a0
101 changed files with 11534 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { C, STATUS_COLOR } from "../lib/theme";
import { ago, cadenceLabel, dueLine } from "../lib/format";
import { tap } from "../lib/haptics";
import type { DuePerson, Person } from "../lib/types";
// One row, used by both the Today (due) list and the People list.
// `due` rows show overdue info + a quick-log button.
export function PersonRow({ person, due }: { person: Person; due?: DuePerson }) {
const router = useRouter();
const subtitle = due
? dueLine(due.days_since, due.cadence_days ?? 0, due.last_contacted)
: `${ago(person.last_contacted)} · ${cadenceLabel(person.cadence_days)}`;
return (
<Pressable
onPress={() => {
tap();
router.push({ pathname: "/person/[id]", params: { id: person.id } });
}}
style={({ pressed }) => [s.row, pressed && { backgroundColor: C.surface2 }]}
>
{due ? <View style={[s.dot, { backgroundColor: STATUS_COLOR[due.status] }]} /> : null}
<View style={{ flex: 1 }}>
<Text style={s.name} numberOfLines={1}>
{person.full_name}
</Text>
<Text style={s.sub} numberOfLines={1}>
{subtitle}
</Text>
</View>
{due ? (
<Pressable
onPress={(e) => {
e.stopPropagation();
tap();
router.push({
pathname: "/log",
params: { personId: person.id, name: person.full_name },
});
}}
style={({ pressed }) => [s.logBtn, pressed && { opacity: 0.6 }]}
hitSlop={8}
>
<Text style={s.logBtnText}></Text>
</Pressable>
) : null}
</Pressable>
);
}
const s = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
},
dot: { width: 8, height: 8, borderRadius: 4 },
name: { color: C.text, fontSize: 16, fontWeight: "600" },
sub: { color: C.muted, fontSize: 13, marginTop: 2 },
logBtn: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: C.surface2,
borderWidth: 1,
borderColor: C.border,
alignItems: "center",
justifyContent: "center",
},
logBtnText: { color: C.accent, fontSize: 16, fontWeight: "700" },
});

164
app/src/components/ui.tsx Normal file
View File

@@ -0,0 +1,164 @@
import React from "react";
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { C } from "../lib/theme";
import { tap } from "../lib/haptics";
export function Button({
title,
onPress,
kind = "primary",
small,
}: {
title: string;
onPress: () => void;
kind?: "primary" | "ghost" | "danger";
small?: boolean;
}) {
return (
<Pressable
onPress={() => {
tap();
onPress();
}}
style={({ pressed }) => [
s.btn,
small && s.btnSmall,
kind === "primary" && { backgroundColor: C.accent },
kind === "ghost" && { backgroundColor: C.surface2, borderWidth: 1, borderColor: C.border },
kind === "danger" && { backgroundColor: "transparent", borderWidth: 1, borderColor: C.danger },
pressed && { opacity: 0.7 },
]}
>
<Text
style={[
s.btnText,
small && { fontSize: 13 },
kind === "primary" && { color: "#1A0E10", fontWeight: "700" },
kind === "ghost" && { color: C.text },
kind === "danger" && { color: C.danger },
]}
>
{title}
</Text>
</Pressable>
);
}
export function Field({
label,
...props
}: TextInputProps & { label: string }) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>{label}</Text>
<TextInput
placeholderTextColor={C.muted}
style={[s.input, props.multiline && { minHeight: 80, textAlignVertical: "top" }]}
{...props}
/>
</View>
);
}
export function Chips<T>({
options,
value,
onChange,
getLabel,
getKey,
}: {
options: T[];
value: T | null;
onChange: (v: T) => void;
getLabel: (v: T) => string;
getKey: (v: T) => string;
}) {
return (
<View style={s.chips}>
{options.map((o) => {
const active = value != null && getKey(o) === getKey(value);
return (
<Pressable
key={getKey(o)}
onPress={() => {
tap();
onChange(o);
}}
style={[s.chip, active && { backgroundColor: C.accent, borderColor: C.accent }]}
>
<Text style={[s.chipText, active && { color: "#1A0E10", fontWeight: "700" }]}>
{getLabel(o)}
</Text>
</Pressable>
);
})}
</View>
);
}
export function TagPill({ tag }: { tag: string }) {
return (
<View style={s.tag}>
<Text style={s.tagText}>{tag}</Text>
</View>
);
}
export function Empty({ title, hint }: { title: string; hint?: string }) {
return (
<View style={s.empty}>
<Text style={s.emptyTitle}>{title}</Text>
{hint ? <Text style={s.emptyHint}>{hint}</Text> : null}
</View>
);
}
const s = StyleSheet.create({
btn: {
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 18,
alignItems: "center",
justifyContent: "center",
},
btnSmall: { paddingVertical: 7, paddingHorizontal: 12 },
btnText: { fontSize: 15, fontWeight: "600" },
label: { color: C.muted, fontSize: 13, marginBottom: 6 },
input: {
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
color: C.text,
fontSize: 15,
},
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
borderRadius: 16,
borderWidth: 1,
borderColor: C.border,
backgroundColor: C.surface,
paddingHorizontal: 12,
paddingVertical: 6,
},
chipText: { color: C.text, fontSize: 13 },
tag: {
backgroundColor: C.surface2,
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 2,
},
tagText: { color: C.muted, fontSize: 12 },
empty: { alignItems: "center", paddingVertical: 48, paddingHorizontal: 24 },
emptyTitle: { color: C.text, fontSize: 16, fontWeight: "600", marginBottom: 6 },
emptyHint: { color: C.muted, fontSize: 14, textAlign: "center", lineHeight: 20 },
});