Files
llink/js/mobile/src/features/settings/SettingsScreen.tsx
T
Arjun Patel 3d80ac2993 feat: increase feature parity between mobile and desktop (#298)
* mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: network member management and avatars (parity phase 2)

- New NetworkSettingsScreen (reachable from the stream-list header) lists
  members with admin remove, an invite-by-email sheet, and pending
  invitations with revoke — backed by new use-member-management hooks.
- Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/
  getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a
  use-avatar-url hook, and image rendering in the shared Avatar component.
  AccountScreen gains a tap-to-change profile picture via expo-image-picker.
- auth-store gains refreshUser to pick up avatar changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: task particles — view, edit, and compose (parity phase 3)

Bring the task particle to parity with desktop's richer model:

- Replace the thin `quest` schema with desktop's `task` model
  (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done)
  in the discriminated union, the Firestore converter, and consumers
  (StreamCard, FallbackParticleView).
- New TaskParticleView renders an editable card (round done checkbox,
  title, notes, checklist with add/toggle/edit/remove, assignee chips)
  persisting each edit to Firestore; an 8s dwell auto-advances and
  field focus suspends playback. Wired into StreamView's render switch.
- Compose: a task button in the ComposeDock opens a TaskComposeSheet
  (createTaskParticle helper). Gated off in the new-stream flow, where a
  stream's first particle must be text or media.

Note: particles are written client-side to Firestore, matching desktop;
Orion's REST validator still only accepts `quest`, which is a pre-existing
inconsistency to reconcile backend-side separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: paper and file particle views (parity phase 4)

- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: billing & usage in network settings (parity phase 5)

Surface the network plan, daily usage, and Stripe management — all backed
by client methods that already existed. New use-billing hooks and a
BillingSection (mirroring desktop): every member sees the plan + usage
summary; admins get cadence selection + "Upgrade to Pro" (checkout) and
"Manage subscription" (portal), opening Stripe in the system browser.
Added to NetworkSettingsScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 10:38:16 -07:00

109 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Pressable, Switch, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { apiClient } from '@/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { logError, toUserMessage } from '@/lib/errors';
import { toast } from 'sonner-native';
import type { RootStackScreenProps } from '@/navigation/types';
export function SettingsScreen({
navigation,
}: RootStackScreenProps<'Settings'>) {
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const [emailNotifications, setEmailNotifications] = useState(
user?.email_notifications_enabled ?? true,
);
const version = Constants.expoConfig?.version ?? '—';
// Optimistic toggle — flip the local + auth-store state immediately, roll
// back on failure. Mirrors desktop's settings-page handler.
const handleToggleEmailNotifications = async (checked: boolean) => {
setEmailNotifications(checked);
useAuthStore.setState((state) => ({
user: state.user
? { ...state.user, email_notifications_enabled: checked }
: null,
}));
try {
await apiClient.updateSettings({ email_notifications_enabled: checked });
} catch (err) {
setEmailNotifications(!checked);
useAuthStore.setState((state) => ({
user: state.user
? { ...state.user, email_notifications_enabled: !checked }
: null,
}));
toast.error(toUserMessage(err));
logError(err, { scope: 'settings.emailNotifications' });
}
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
Settings
</Text>
<View className="w-8" />
</View>
<View className="px-4 py-4">
<SettingsGroup title="Notifications">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">
Email notifications
</Text>
<Switch
value={emailNotifications}
onValueChange={handleToggleEmailNotifications}
/>
</View>
</SettingsGroup>
<SettingsGroup title="About">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">Version</Text>
<Text className="text-muted-foreground text-base">{version}</Text>
</View>
</SettingsGroup>
<View className="mt-4">
<Pressable
onPress={() => void signOut()}
disabled={isSigningOut}
className="px-4 py-3 active:bg-accent rounded-xl"
>
<Text className="text-destructive text-base font-medium">
{isSigningOut ? 'Signing out…' : 'Sign out'}
</Text>
</Pressable>
</View>
</View>
</SafeAreaView>
);
}
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-2">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
{title}
</Text>
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
</View>
);
}