Production deployment setup + feature complete
- Dockerfile + deploy.sh for Hetzner server - Email verification via Better Auth + Resend - Invite code flow (6-digit OTP, generate/join) - Settlement share percent fix (payer vs debtor) - OCR scanner fixes (date display, retry, viewfinder) - app.json icon/splash/adaptive-icon configured - iOS deployment target 15.5 (ML Kit requirement) - DB migration 0014: household_invitations table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useShoppingList, type ShoppingItem } from "@/src/hooks/useShoppingList";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const ACCENT = "#16a34a";
|
||||
|
||||
function StatusDot({ status }: { status: "connecting" | "connected" | "offline" }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === "connected") {
|
||||
return <View className="w-2 h-2 rounded-full bg-green-500" />;
|
||||
}
|
||||
return (
|
||||
<View className="flex-row items-center gap-1">
|
||||
<View className="w-2 h-2 rounded-full bg-gray-400" />
|
||||
{status === "offline" && (
|
||||
<Text className="text-xs text-gray-400">{t("shopping.offline")}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ShoppingItemRow({
|
||||
item,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
item: ShoppingItem;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isChecked = item.checkedBy !== null;
|
||||
return (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||
<TouchableOpacity onPress={onToggle} className="mr-3 active:opacity-60">
|
||||
<Ionicons
|
||||
name={isChecked ? "checkbox" : "square-outline"}
|
||||
size={24}
|
||||
color={isChecked ? "#9ca3af" : ACCENT}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
className="flex-1 text-base"
|
||||
style={{
|
||||
color: isChecked ? "#9ca3af" : "#111827",
|
||||
textDecorationLine: isChecked ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{item.quantity ? (
|
||||
<Text style={{ color: "#9ca3af", fontSize: 13 }}> {item.quantity}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
|
||||
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShoppingListScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const { items, status, addItem, toggleItem, deleteItem, deleteChecked } = useShoppingList();
|
||||
const [text, setText] = useState("");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const unchecked = items.filter((i) => i.checkedBy === null);
|
||||
const checked = items.filter((i) => i.checkedBy !== null);
|
||||
const sorted = [...unchecked, ...checked];
|
||||
const hasChecked = checked.length > 0;
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
addItem(trimmed, quantity.trim() || undefined);
|
||||
setText("");
|
||||
setQuantity("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-gray-50"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: insets.top,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
}}
|
||||
className="px-4 pb-3 flex-row items-center justify-between"
|
||||
>
|
||||
<Text className="text-xl font-bold text-gray-900">{t("shopping.title")}</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{hasChecked && (
|
||||
<Pressable
|
||||
onPress={deleteChecked}
|
||||
className="rounded-full px-3 py-1 active:opacity-70"
|
||||
style={{ backgroundColor: "#f3f4f6" }}
|
||||
>
|
||||
<Text className="text-xs font-medium text-gray-600">
|
||||
{t("shopping.deleteChecked")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<StatusDot status={status} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => {
|
||||
const isFirstChecked =
|
||||
item.checkedBy !== null &&
|
||||
(index === 0 || sorted[index - 1]?.checkedBy === null);
|
||||
return (
|
||||
<>
|
||||
{isFirstChecked && unchecked.length > 0 && (
|
||||
<View className="h-px bg-gray-200 mx-4 my-1" />
|
||||
)}
|
||||
<ShoppingItemRow
|
||||
item={item}
|
||||
onToggle={() => toggleItem(item)}
|
||||
onDelete={() => deleteItem(item.id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View className="h-px bg-gray-100 ml-14" />}
|
||||
ListEmptyComponent={
|
||||
<View className="flex-1 items-center justify-center py-24">
|
||||
<Ionicons
|
||||
name="cart-outline"
|
||||
size={48}
|
||||
color="#d1d5db"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Text className="text-base font-medium text-gray-700 mb-1">
|
||||
{t("shopping.empty")}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400 text-center px-8">
|
||||
{t("shopping.emptyHint")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={sorted.length === 0 ? { flex: 1 } : { paddingBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Input bar */}
|
||||
<View
|
||||
style={{ paddingBottom: insets.bottom || 16 }}
|
||||
className="px-4 pt-3 bg-white border-t border-gray-100"
|
||||
>
|
||||
<View className="flex-row items-center gap-3 mb-2">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder={t("shopping.placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAdd}
|
||||
blurOnSubmit={false}
|
||||
className="flex-1 text-base text-gray-900 py-2"
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleAdd}
|
||||
disabled={!text.trim()}
|
||||
style={{ backgroundColor: text.trim() ? ACCENT : "#e5e7eb" }}
|
||||
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
|
||||
>
|
||||
<Ionicons name="arrow-up" size={18} color={text.trim() ? "#fff" : "#9ca3af"} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
value={quantity}
|
||||
onChangeText={setQuantity}
|
||||
placeholder={t("shopping.quantityPlaceholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAdd}
|
||||
blurOnSubmit={false}
|
||||
className="text-sm text-gray-600 py-1"
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user