diff --git a/__tests__/components/DetailViewHiddenFields.test.tsx b/__tests__/components/DetailViewHiddenFields.test.tsx
index ef3b0b5..862ba15 100644
--- a/__tests__/components/DetailViewHiddenFields.test.tsx
+++ b/__tests__/components/DetailViewHiddenFields.test.tsx
@@ -44,4 +44,47 @@ describe("DetailViewRenderer — fallback field filtering", () => {
// A field the metadata marks hidden is filtered out of the fallback too.
expect(queryByText("Secret Token")).toBeNull();
});
+
+ it("collapses empty-valued fields in the auto-layout", () => {
+ const sparse = {
+ id: "rec_2",
+ subject: "Has a subject",
+ description: "", // empty string
+ due_date: null, // null
+ tags: [], // empty array
+ };
+ const sparseFields: FieldDefinition[] = [
+ { name: "subject", label: "Subject", type: "text" },
+ { name: "description", label: "Description", type: "textarea" },
+ { name: "due_date", label: "Due Date", type: "date" },
+ { name: "tags", label: "Tags", type: "tags" },
+ ];
+ const { queryByText, getByText } = render(
+ ,
+ );
+
+ // Populated field shows; empty ones are collapsed (no row of "—").
+ expect(getByText("Subject")).toBeTruthy();
+ expect(queryByText("Description")).toBeNull();
+ expect(queryByText("Due Date")).toBeNull();
+ expect(queryByText("Tags")).toBeNull();
+ });
+
+ it("keeps every field (even empty) when a curated view lists them", () => {
+ const sparse = { id: "rec_3", subject: "S", description: "" };
+ const sparseFields: FieldDefinition[] = [
+ { name: "subject", label: "Subject", type: "text" },
+ { name: "description", label: "Description", type: "textarea" },
+ ];
+ const { getByText } = render(
+ ,
+ );
+ // Curated view honours the author's choice — empty Description still shows.
+ expect(getByText("Subject")).toBeTruthy();
+ expect(getByText("Description")).toBeTruthy();
+ });
});
diff --git a/__tests__/components/OptionBadge.test.tsx b/__tests__/components/OptionBadge.test.tsx
new file mode 100644
index 0000000..ef7c120
--- /dev/null
+++ b/__tests__/components/OptionBadge.test.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+import { Text } from "react-native";
+
+import { OptionBadge } from "~/components/renderers/fields/FieldRenderer";
+import type { FieldDefinition } from "~/components/renderers/types";
+
+/**
+ * Select/status option colours in object metadata are usually hex (`#10B981`),
+ * which the named-colour Tailwind map can't resolve — they fell back to a flat
+ * grey pill. `OptionBadge` renders the option's hex as an inline tint so list
+ * rows and the detail view show the real status colour.
+ */
+describe("OptionBadge", () => {
+ const statusField: FieldDefinition = {
+ name: "status",
+ type: "status",
+ options: [
+ { value: "completed", label: "Completed", color: "#10B981" },
+ { value: "blocked", label: "Blocked", color: "red" },
+ ],
+ };
+
+ /** Pull the text node's resolved style color for an option value. */
+ function colorFor(value: string): unknown {
+ const { UNSAFE_getByType } = render();
+ const text = UNSAFE_getByType(Text);
+ const style = Array.isArray(text.props.style)
+ ? Object.assign({}, ...text.props.style.filter(Boolean))
+ : text.props.style;
+ return style?.color;
+ }
+
+ it("renders the option label", () => {
+ const { getByText } = render();
+ expect(getByText("Completed")).toBeTruthy();
+ });
+
+ it("applies a hex option colour as the badge text colour", () => {
+ expect(colorFor("completed")).toBe("#10B981");
+ });
+
+ it("does not inline-style a named colour (uses the Tailwind class path)", () => {
+ // Named colours flow through className, not an inline `color` style.
+ expect(colorFor("blocked")).toBeUndefined();
+ });
+
+ it("renders nothing for a non-select field or empty value", () => {
+ const text = { name: "subject", type: "text" } as FieldDefinition;
+ expect(render().toJSON()).toBeNull();
+ expect(render().toJSON()).toBeNull();
+ });
+});
diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx
index ef122cf..97797bd 100644
--- a/app/(tabs)/notifications.tsx
+++ b/app/(tabs)/notifications.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-import { Bell, CheckCheck, Circle } from "lucide-react-native";
+import { Bell, CheckCheck, Circle, WifiOff } from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
@@ -68,8 +68,10 @@ export default function NotificationsScreen() {
notifications,
unreadCount,
isLoading,
+ error,
markRead,
markAllRead,
+ refetch,
} = useNotifications();
const router = useRouter();
const { t } = useTranslation();
@@ -116,8 +118,22 @@ export default function NotificationsScreen() {
)}
- {/* Empty state */}
- {!isLoading && notifications.length === 0 && (
+ {/* Service-unavailable state — distinct from a genuinely empty inbox so a
+ failed fetch (e.g. the notifications service isn't mounted) doesn't
+ masquerade as "you're all caught up". */}
+ {!isLoading && notifications.length === 0 && error && (
+ void refetch()}
+ />
+ )}
+
+ {/* Empty state — genuinely no notifications */}
+ {!isLoading && notifications.length === 0 && !error && (
f.name),
);
+ const isEmptyValue = (v: unknown) =>
+ v == null || v === "" || (Array.isArray(v) && v.length === 0);
+
const buildSections = (allKeys: string[]): FormSection[] => {
const keys = allKeys.filter(
(k) =>
!k.startsWith("_") &&
k !== "id" &&
!INTERNAL_FIELDS.has(k) &&
- !hiddenByMeta.has(k),
+ !hiddenByMeta.has(k) &&
+ // In the auto-layout (no curated view) an empty field is just a "—"
+ // taking up a line; collapse it. Curated views still show every
+ // field the author chose, empty or not.
+ !(record != null && isEmptyValue(record[k])),
);
const business = keys.filter((k) => !SYSTEM_FIELDS.has(k));
const system = keys.filter((k) => SYSTEM_FIELDS.has(k));
diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx
index f6419b3..ce43f16 100644
--- a/components/renderers/ListViewRenderer.tsx
+++ b/components/renderers/ListViewRenderer.tsx
@@ -14,7 +14,7 @@ import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { Button } from "~/components/ui/Button";
import { SearchBar } from "~/components/common/SearchBar";
import { BatchActionBar } from "~/components/batch/BatchActionBar";
-import { formatDisplayValue } from "./fields/FieldRenderer";
+import { formatDisplayValue, isSelectType, OptionBadge } from "./fields/FieldRenderer";
import { FilterDrawer, FilterButton } from "./FilterDrawer";
import { SwipeableRow } from "./SwipeableRow";
import type {
@@ -442,11 +442,19 @@ export function ListViewRenderer({
{secondaryCols.map((col) => {
const fieldDef = fields.find((f) => f.name === col.field);
- const displayVal = formatDisplayValue(
- item[col.field],
- (fieldDef?.type ?? col.type ?? "text") as FieldType,
- fieldDef,
- );
+ const fieldType = (fieldDef?.type ?? col.type ?? "text") as FieldType;
+ // Select/status values render as the option's coloured badge
+ // (the colour carries the meaning), not flat "Label: value".
+ if (isSelectType(fieldType)) {
+ return (
+
+ );
+ }
+ const displayVal = formatDisplayValue(item[col.field], fieldType, fieldDef);
return (
diff --git a/components/renderers/fields/FieldRenderer.tsx b/components/renderers/fields/FieldRenderer.tsx
index aeaf3b4..d6eba32 100644
--- a/components/renderers/fields/FieldRenderer.tsx
+++ b/components/renderers/fields/FieldRenderer.tsx
@@ -21,6 +21,11 @@ import type { FieldDefinition, FieldType, SelectOption } from "../types";
const SELECT_TYPES = new Set(["select", "radio", "picklist", "status"]);
const MULTI_TYPES = new Set(["multiselect", "checkboxes", "tags"]);
+/** Whether a field type renders as a single coloured option badge. */
+export function isSelectType(type: string | undefined): boolean {
+ return SELECT_TYPES.has(type ?? "");
+}
+
/** Find the option matching `value` in a field's option list. */
function findOption(
field: FieldDefinition | undefined,
@@ -63,8 +68,58 @@ function badgeClasses(color: string | undefined): string {
}
}
+/** Expand a `#rgb`/`#rrggbb` colour to a 6-digit hex, or null if not hex. */
+function normalizeHex(color: string | undefined): string | null {
+ const c = (color ?? "").trim();
+ if (/^#[0-9a-f]{6}$/i.test(c)) return c;
+ if (/^#[0-9a-f]{3}$/i.test(c)) {
+ return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
+ }
+ return null;
+}
+
+/**
+ * Inline badge style for a hex option colour — a light tint background with the
+ * colour itself as the text. Metadata authors often specify hex (`#10B981`)
+ * rather than a named colour, which `badgeClasses` can't map; without this they
+ * all fall back to a flat grey pill.
+ */
+function hexBadgeStyle(hex: string): { bg: { backgroundColor: string }; fg: { color: string } } {
+ // 8-digit hex appends an alpha byte; `1F` ≈ 12% opacity for the tint.
+ return { bg: { backgroundColor: `${hex}1F` }, fg: { color: hex } };
+}
+
+/**
+ * A coloured option badge for a select/status field value — the same pill the
+ * detail/form renderers use, exported so compact surfaces (list rows, related
+ * lists) render the option's colour instead of flat text. Returns null for a
+ * non-select field or an empty value.
+ */
+export function OptionBadge({
+ field,
+ value,
+}: {
+ field: FieldDefinition | undefined;
+ value: unknown;
+}) {
+ if (!field || !isSelectType(field.type) || value == null || value === "") return null;
+ const opt = findOption(field, value);
+ return ;
+}
+
/** A coloured pill for a select/status value. */
function StatusBadge({ label, color }: { label: string; color?: string }) {
+ const hex = normalizeHex(color);
+ if (hex) {
+ const { bg, fg } = hexBadgeStyle(hex);
+ return (
+
+
+ {label}
+
+
+ );
+ }
return (
{label}
diff --git a/locales/ar.json b/locales/ar.json
index 52cc96e..ebf30b3 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -151,7 +151,9 @@
"markAllRead": "تحديد الكل كمقروء",
"markAllReadA11y": "تحديد جميع الإشعارات كمقروءة",
"emptyTitle": "لا توجد إشعارات",
- "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا."
+ "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا.",
+ "unavailableTitle": "الإشعارات غير متاحة",
+ "unavailableDesc": "تعذّر الوصول إلى خدمة الإشعارات. اسحب للتحديث أو حاول مرة أخرى."
},
"more": {
"profileFallbackName": "مستخدم",
diff --git a/locales/en.json b/locales/en.json
index d72ab84..87810ef 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -148,7 +148,9 @@
"markAllRead": "Mark all read",
"markAllReadA11y": "Mark all notifications read",
"emptyTitle": "No Notifications",
- "emptyDesc": "You're all caught up. New notifications will appear here."
+ "emptyDesc": "You're all caught up. New notifications will appear here.",
+ "unavailableTitle": "Notifications Unavailable",
+ "unavailableDesc": "We couldn't reach the notifications service. Pull to refresh or try again."
},
"more": {
"profileFallbackName": "User",
diff --git a/locales/zh.json b/locales/zh.json
index 223973b..2f49418 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -146,7 +146,9 @@
"markAllRead": "全部标为已读",
"markAllReadA11y": "将所有通知标为已读",
"emptyTitle": "暂无通知",
- "emptyDesc": "你已全部处理完毕。新通知将显示在这里。"
+ "emptyDesc": "你已全部处理完毕。新通知将显示在这里。",
+ "unavailableTitle": "通知不可用",
+ "unavailableDesc": "无法连接通知服务。下拉刷新或稍后重试。"
},
"more": {
"profileFallbackName": "用户",