Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s
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:
78
app/src/components/PersonRow.tsx
Normal file
78
app/src/components/PersonRow.tsx
Normal 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
164
app/src/components/ui.tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user