Compare commits

..
1 Commits
Author SHA1 Message Date
Tomáš Dvořák 604319ee06 feat(mobile): env editing, domains, scaling, notifications (#61)
Companion app was read-mostly — quick fixes still needed a workstation.

- service/variables: full env editor — add/edit/delete rows, secret
  toggle, masked-secret preservation (untouched secrets resubmit the
  "********" placeholder so stored ciphertext survives the PUT).
- service/domains: list with default/TLS status, add, remove,
  set-default.
- service/settings: replicas stepper (0–16), container port, scale-to-
  zero toggle + idle minutes — saves via PUT /services/:id.
- (tabs)/notifications: alerts tab with unread badge on the tab bar,
  mark-read per item and mark-all.
- Service detail gains a Manage section linking all three screens.

State initializes at mount inside a child component rather than an
effect — post-save refetches no longer clobber in-flight edits, and
the react-hooks/set-state-in-effect lint stays clean.

Verified: tsc --noEmit, expo lint, expo export (android bundle,
1378 modules — router graph compiles).

Authored By: TDvorak <[email protected]>
2026-10-09 22:33:06 +02:00
8 changed files with 758 additions and 0 deletions

No files matched your search

+93
View File
@@ -68,6 +68,36 @@ export interface Template {
icon?: string;
}
export interface Variable {
id?: string;
key: string;
value: string;
is_secret: boolean;
}
export interface ServiceDomain {
id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
}
export interface AppNotification {
id: string;
kind: string;
title: string;
body?: string;
resource_type?: string;
resource_id?: string;
read_at?: string | null;
created_at?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
export class ApiError extends Error {
constructor(
public status: number,
@@ -145,6 +175,69 @@ export class Api {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
}
serviceVariables(id: string) {
return this.req<{ variables: Variable[] }>('GET', `/services/${id}/variables`).then(
(r) => r.variables ?? [],
);
}
updateVariables(id: string, variables: { key: string; value: string; is_secret: boolean }[]) {
return this.req<unknown>('PUT', `/services/${id}/variables`, { variables });
}
serviceDomains(id: string) {
return this.req<{ domains: ServiceDomain[] }>('GET', `/services/${id}/domains`).then(
(r) => r.domains ?? [],
);
}
addDomain(id: string, domain: string) {
return this.req<unknown>('POST', `/services/${id}/domains`, { domain });
}
deleteDomain(id: string, domainId: string) {
return this.req<unknown>('DELETE', `/services/${id}/domains/${domainId}`);
}
setDefaultDomain(id: string, domainId: string) {
return this.req<unknown>('POST', `/services/${id}/domains/${domainId}/default`);
}
updateService(
id: string,
patch: Partial<{
name: string;
command: string;
replicas: number;
port: number;
domain: string;
restart_policy: string;
healthcheck_path: string;
cpu: string;
memory: string;
sleep_enabled: boolean;
sleep_idle_minutes: number;
maintenance_mode: boolean;
}>,
) {
return this.req<unknown>('PUT', `/services/${id}`, patch);
}
notifications() {
return this.req<{ notifications: AppNotification[]; unread: number }>(
'GET',
'/notifications?limit=50',
);
}
markNotificationRead(id: string) {
return this.req<unknown>('POST', `/notifications/${id}/read`);
}
markAllNotificationsRead() {
return this.req<unknown>('POST', '/notifications/read-all');
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
+20
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
export default function TabsLayout() {
const api = useApi();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 60_000,
});
const unread = notifs.data?.unread ?? 0;
return (
<Tabs
screenOptions={{
@@ -41,6 +51,16 @@ export default function TabsLayout() {
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Alerts',
tabBarBadge: unread > 0 ? unread : undefined,
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
const KIND_COLOR: Record<string, string> = {
deployment_failed: colors.err,
deployment: colors.accent,
alert: colors.warn,
scale: colors.info,
};
export default function Notifications() {
const api = useApi();
const qc = useQueryClient();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 30_000,
});
const markAll = useMutation({
mutationFn: () => api.markAllNotificationsRead(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
const markRead = useMutation({
mutationFn: (id: string) => api.markNotificationRead(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
if (notifs.isLoading) return <Loading />;
if (notifs.error) return <ErrorBox error={notifs.error} />;
const items = notifs.data?.notifications ?? [];
const unread = notifs.data?.unread ?? 0;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={notifs.isRefetching}
onRefresh={() => notifs.refetch()}
tintColor={colors.accent}
/>
}
ListHeaderComponent={
unread > 0 ? (
<ActionButton
label={`Mark all read (${unread})`}
disabled={markAll.isPending}
onPress={() => markAll.mutate()}
/>
) : null
}
ListEmptyComponent={<Empty text="No notifications" />}
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => {
const isUnread = !item.read_at;
const dot = KIND_COLOR[item.kind] ?? colors.accent;
return (
<Card
style={isUnread ? { borderColor: colors.accent } : undefined}
>
<View style={styles.row}>
<View style={[styles.dot, { backgroundColor: isUnread ? dot : colors.border }]} />
<View style={{ flex: 1 }}>
<Text style={[styles.title, !isUnread && { color: colors.textDim }]}>
{item.title}
</Text>
{item.body ? <Text style={styles.body}>{item.body}</Text> : null}
<Text style={styles.meta}>
{item.kind}
{item.created_at ? ` · ${relTime(item.created_at)}` : ''}
</Text>
</View>
{isUnread ? (
<Text
style={styles.markRead}
onPress={() => markRead.mutate(item.id)}
>
Mark read
</Text>
) : null}
</View>
</Card>
);
}}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
row: { flexDirection: 'row', alignItems: 'flex-start' },
dot: { width: 8, height: 8, borderRadius: 4, marginTop: 5, marginRight: 10 },
title: { color: colors.text, fontSize: 15, fontWeight: '600' },
body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 },
meta: { color: colors.textDim, fontSize: 11, marginTop: 6 },
markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 },
});
+3
View File
@@ -37,6 +37,9 @@ function AuthGate() {
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
</Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} />
</Stack>
+23
View File
@@ -11,6 +11,7 @@ import {
Empty,
ErrorBox,
Loading,
Row,
SectionTitle,
StatusBadge,
} from '../../components';
@@ -142,6 +143,28 @@ export default function ServiceDetail() {
/>
</View>
<SectionTitle>Manage</SectionTitle>
<Card>
<Row
title="Variables"
subtitle="Environment and secrets"
onPress={() =>
router.push(`/service/variables?id=${s.id}&name=${encodeURIComponent(s.name)}`)
}
/>
<Row
title="Domains"
subtitle={s.domain ?? 'Hostnames routed to this service'}
onPress={() => router.push(`/service/domains?id=${s.id}`)}
/>
<Row
title="Scale & sleep"
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
(s.sleep_enabled ? ` · sleeps after ${s.sleep_idle_minutes}m idle` : '')}
onPress={() => router.push(`/service/settings?id=${s.id}`)}
/>
</Card>
{s.sleep_enabled ? (
<Text style={styles.hint}>
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
+151
View File
@@ -0,0 +1,151 @@
import React, { useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
export default function ServiceDomains() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const qc = useQueryClient();
const [newDomain, setNewDomain] = useState('');
const domains = useQuery({
queryKey: ['domains', id],
queryFn: () => api.serviceDomains(id!),
enabled: !!id,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['domains', id] });
qc.invalidateQueries({ queryKey: ['service', id] });
};
const onErr = (e: unknown) =>
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
const add = useMutation({
mutationFn: (d: string) => api.addDomain(id!, d),
onSuccess: () => {
setNewDomain('');
invalidate();
},
onError: onErr,
});
const del = useMutation({
mutationFn: (domainId: string) => api.deleteDomain(id!, domainId),
onSuccess: invalidate,
onError: onErr,
});
const makeDefault = useMutation({
mutationFn: (domainId: string) => api.setDefaultDomain(id!, domainId),
onSuccess: invalidate,
onError: onErr,
});
if (domains.isLoading) return <Loading />;
if (domains.error) return <ErrorBox error={domains.error} />;
const submit = () => {
const d = newDomain.trim().toLowerCase();
if (d) add.mutate(d);
};
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<Text style={styles.hint}>
Hostnames routed to this service. The default domain is the one shown on cards and used
for generated links.
</Text>
{domains.data?.length === 0 ? <Empty text="No domains configured" /> : null}
{(domains.data ?? []).map((d) => (
<Card key={d.id}>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.domain}>{d.domain}</Text>
<Text style={styles.meta}>
{d.is_default ? 'default · ' : ''}
{d.cert_status ? `tls: ${d.cert_status}` : 'tls pending'}
</Text>
</View>
{!d.is_default ? (
<TouchableOpacity
onPress={() => makeDefault.mutate(d.id)}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="star-outline" size={20} color={colors.accent} />
</TouchableOpacity>
) : (
<Ionicons name="star" size={20} color={colors.warn} style={styles.iconBtn} />
)}
<TouchableOpacity
onPress={() =>
Alert.alert('Remove domain', `Detach ${d.domain} from this service?`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => del.mutate(d.id) },
])
}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="trash-outline" size={18} color={colors.err} />
</TouchableOpacity>
</View>
</Card>
))}
<View style={styles.addRow}>
<TextInput
style={styles.input}
value={newDomain}
onChangeText={setNewDomain}
placeholder="app.example.com"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
onSubmitEditing={submit}
/>
<ActionButton
label={add.isPending ? '…' : 'Add'}
kind="primary"
disabled={add.isPending || newDomain.trim() === ''}
onPress={submit}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
row: { flexDirection: 'row', alignItems: 'center' },
domain: { color: colors.text, fontSize: 15, fontWeight: '600' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 3 },
iconBtn: { padding: 6, marginLeft: 10 },
addRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
input: {
flex: 1,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
padding: 12,
marginRight: 8,
fontSize: 14,
},
});
+174
View File
@@ -0,0 +1,174 @@
import React, { useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { Service } from '../../api';
import { colors } from '../../theme';
import { ActionButton, Card, ErrorBox, Loading, SectionTitle } from '../../components';
export default function ServiceSettings() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const service = useQuery({
queryKey: ['service', id],
queryFn: () => api.service(id!),
enabled: !!id,
});
if (service.isLoading) return <Loading />;
if (service.error || !service.data) {
return <ErrorBox error={service.error ?? new Error('Not found')} />;
}
// Form mounts once data exists; its state initializes from the fetch.
return <Form serviceId={id!} initial={service.data} />;
}
function Form({ serviceId, initial }: { serviceId: string; initial: Service }) {
const api = useApi();
const qc = useQueryClient();
const [replicas, setReplicas] = useState(initial.replicas ?? 1);
const [port, setPort] = useState(String(initial.port ?? 0));
const [sleepEnabled, setSleepEnabled] = useState(!!initial.sleep_enabled);
const [sleepMinutes, setSleepMinutes] = useState(
String(initial.sleep_idle_minutes || 30),
);
const save = useMutation({
mutationFn: () =>
api.updateService(serviceId, {
replicas,
port: parseInt(port, 10) || 0,
sleep_enabled: sleepEnabled,
sleep_idle_minutes: Math.min(1440, Math.max(1, parseInt(sleepMinutes, 10) || 30)),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['service', serviceId] });
qc.invalidateQueries({ queryKey: ['services'] });
Alert.alert('Saved', 'Changes apply on the next deploy.');
},
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
});
const clampReplicas = (n: number) => Math.min(16, Math.max(0, n));
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<SectionTitle>Scaling</SectionTitle>
<Card>
<View style={styles.stepRow}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Replicas</Text>
<Text style={styles.sub}>Containers run per deploy; 0 parks the service.</Text>
</View>
<TouchableOpacity
style={styles.stepBtn}
onPress={() => setReplicas((r) => clampReplicas(r - 1))}
disabled={replicas <= 0}
>
<Text style={styles.stepText}>−</Text>
</TouchableOpacity>
<Text style={styles.stepValue}>{replicas}</Text>
<TouchableOpacity
style={styles.stepBtn}
onPress={() => setReplicas((r) => clampReplicas(r + 1))}
disabled={replicas >= 16}
>
<Text style={styles.stepText}>+</Text>
</TouchableOpacity>
</View>
<View style={styles.field}>
<Text style={styles.label}>Container port</Text>
<TextInput
style={styles.input}
value={port}
onChangeText={setPort}
keyboardType="number-pad"
placeholder="0 = none"
placeholderTextColor={colors.textDim}
/>
</View>
</Card>
<SectionTitle>Scale to zero</SectionTitle>
<Card>
<View style={styles.switchRow}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Sleep when idle</Text>
<Text style={styles.sub}>Scales to zero after idle; incoming traffic wakes it.</Text>
</View>
<Switch
value={sleepEnabled}
onValueChange={setSleepEnabled}
trackColor={{ false: colors.border, true: colors.accentDim }}
thumbColor={sleepEnabled ? colors.accent : colors.textDim}
/>
</View>
{sleepEnabled ? (
<View style={styles.field}>
<Text style={styles.label}>Idle minutes before sleep (1–1440)</Text>
<TextInput
style={styles.input}
value={sleepMinutes}
onChangeText={setSleepMinutes}
keyboardType="number-pad"
placeholder="30"
placeholderTextColor={colors.textDim}
/>
</View>
) : null}
</Card>
<ActionButton
label={save.isPending ? 'Saving…' : 'Save settings'}
kind="primary"
disabled={save.isPending}
onPress={() => save.mutate()}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
label: { color: colors.text, fontSize: 15, fontWeight: '600' },
sub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
stepRow: { flexDirection: 'row', alignItems: 'center' },
stepBtn: {
width: 38,
height: 38,
borderRadius: 8,
backgroundColor: colors.surfaceAlt,
alignItems: 'center',
justifyContent: 'center',
},
stepText: { color: colors.text, fontSize: 20, fontWeight: '700' },
stepValue: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
minWidth: 36,
textAlign: 'center',
},
switchRow: { flexDirection: 'row', alignItems: 'center' },
field: { marginTop: 12 },
input: {
color: colors.text,
backgroundColor: colors.surfaceAlt,
borderRadius: 8,
padding: 10,
marginTop: 6,
fontSize: 14,
},
});
+187
View File
@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { MASKED_SECRET, Variable } from '../../api';
import { colors } from '../../theme';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
interface Row {
key: string;
value: string;
is_secret: boolean;
// Secret untouched by the user — submits the masked placeholder so the
// server keeps the stored ciphertext.
keepSecret: boolean;
}
export default function ServiceVariables() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const vars = useQuery({
queryKey: ['variables', id],
queryFn: () => api.serviceVariables(id!),
enabled: !!id,
});
if (vars.isLoading) return <Loading />;
if (vars.error) return <ErrorBox error={vars.error} />;
if (!vars.data) return <ErrorBox error={new Error('Not found')} />;
// Editor mounts once data exists; its state initializes from the fetch.
return <Editor serviceId={id!} name={name} initial={vars.data} />;
}
function Editor({
serviceId,
name,
initial,
}: {
serviceId: string;
name?: string;
initial: Variable[];
}) {
const api = useApi();
const qc = useQueryClient();
const [rows, setRows] = useState<Row[]>(() =>
initial.map((v) => ({
key: v.key,
value: v.is_secret ? '' : v.value,
is_secret: v.is_secret,
keepSecret: v.is_secret,
})),
);
const save = useMutation({
mutationFn: () => {
const clean = rows
.filter((r) => r.key.trim() !== '')
.map((r) => ({
key: r.key.trim(),
value: r.keepSecret ? MASKED_SECRET : r.value,
is_secret: r.is_secret,
}));
return api.updateVariables(serviceId, clean);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['variables', serviceId] });
qc.invalidateQueries({ queryKey: ['env-check', serviceId] });
Alert.alert('Saved', 'Variables apply on the next deploy/restart.');
},
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
});
const set = (i: number, patch: Partial<Row>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text style={styles.hint}>
Variables for {name ?? 'this service'}. Secret values are stored encrypted — leave a
secret blank to keep it, type to replace.
</Text>
{rows.length === 0 ? <Empty text="No variables yet" /> : null}
{rows.map((r, i) => (
<Card key={i}>
<View style={styles.rowHead}>
<TextInput
style={styles.keyInput}
value={r.key}
onChangeText={(t) => set(i, { key: t.toUpperCase().replace(/\s/g, '_') })}
placeholder="KEY"
placeholderTextColor={colors.textDim}
autoCapitalize="characters"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() =>
set(i, r.is_secret ? { is_secret: false, keepSecret: false } : { is_secret: true })
}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons
name={r.is_secret ? 'lock-closed' : 'lock-open-outline'}
size={18}
color={r.is_secret ? colors.warn : colors.textDim}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setRows((rs) => rs.filter((_, j) => j !== i))}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="trash-outline" size={18} color={colors.err} />
</TouchableOpacity>
</View>
<TextInput
style={styles.valueInput}
value={r.value}
onChangeText={(t) => set(i, { value: t, keepSecret: false })}
placeholder={r.keepSecret ? '•••••••• (unchanged)' : 'value'}
placeholderTextColor={colors.textDim}
secureTextEntry={r.is_secret && !r.keepSecret}
autoCapitalize="none"
autoCorrect={false}
multiline={false}
/>
</Card>
))}
<ActionButton
label="+ Add variable"
onPress={() =>
setRows((rs) => [...rs, { key: '', value: '', is_secret: false, keepSecret: false }])
}
/>
<ActionButton
label={save.isPending ? 'Saving…' : 'Save variables'}
kind="primary"
disabled={save.isPending}
onPress={() => save.mutate()}
/>
<View style={{ height: 40 }} />
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
rowHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
keyInput: {
flex: 1,
color: colors.text,
fontSize: 14,
fontWeight: '700',
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
valueInput: {
color: colors.text,
fontSize: 14,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
backgroundColor: colors.surfaceAlt,
borderRadius: 8,
padding: 10,
},
iconBtn: { padding: 6, marginLeft: 6 },
});