mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
custom linear haptics
This commit is contained in:
+21
-4
@@ -39,6 +39,8 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { hapticForToggle } from '@/lib/hapticCatalog';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { isWideWindow } from '@/theme/adaptive';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
@@ -242,7 +244,11 @@ export default function EQScreen() {
|
||||
activeBandId={eq.activeBandId}
|
||||
enabled={eq.enabled}
|
||||
spectrumActive={scopeActive && focused}
|
||||
onSelectBand={eq.selectBand}
|
||||
onSelectBand={(id) => {
|
||||
if (id === eq.activeBandId) return;
|
||||
playHaptic('selection');
|
||||
eq.selectBand(id);
|
||||
}}
|
||||
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
|
||||
/>
|
||||
);
|
||||
@@ -260,8 +266,15 @@ export default function EQScreen() {
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
canAdd={eq.bands.length < EQ_MAX_BANDS}
|
||||
onSelect={eq.selectBand}
|
||||
onAdd={() => eq.addBand()}
|
||||
onSelect={(id) => {
|
||||
if (id === eq.activeBandId) return;
|
||||
playHaptic('selection');
|
||||
eq.selectBand(id);
|
||||
}}
|
||||
onAdd={() => {
|
||||
playHaptic('action');
|
||||
eq.addBand();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -289,7 +302,10 @@ export default function EQScreen() {
|
||||
</View>
|
||||
<Pressable android_ripple={ripple.bounded}
|
||||
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
|
||||
onPress={eq.toggleEnabled}
|
||||
onPress={() => {
|
||||
playHaptic(hapticForToggle(!eq.enabled));
|
||||
eq.toggleEnabled();
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="power"
|
||||
@@ -410,6 +426,7 @@ export default function EQScreen() {
|
||||
icon="remove-circle-outline"
|
||||
onPress={() => {
|
||||
eq.removeBand(activeBand.id);
|
||||
playHaptic('confirm');
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
playTracks
|
||||
} from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { commitHaptic, dragArmHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import {
|
||||
sortTracks,
|
||||
TRACK_SORT_LABELS,
|
||||
@@ -161,12 +161,13 @@ export default function LibraryScreen() {
|
||||
// Multi-select (tracks view): long-press arms it, batch actions live in the
|
||||
// bottom bar, selection order follows the current display order.
|
||||
const enterSelection = (track: DbTrack) => {
|
||||
dragArmHaptic();
|
||||
playHaptic('threshold');
|
||||
setSelectMode(true);
|
||||
setSelectedIds(new Set([track.id]));
|
||||
};
|
||||
|
||||
const toggleSelected = (id: number) => {
|
||||
playHaptic('selection');
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -194,14 +195,14 @@ export default function LibraryScreen() {
|
||||
|
||||
const batchPlayNext = () => {
|
||||
const tracks = selectedDbTracks().map(dbTrackToTrack);
|
||||
commitHaptic();
|
||||
playHaptic('confirm');
|
||||
exitSelection();
|
||||
void enqueueTopMany(tracks);
|
||||
};
|
||||
|
||||
const batchAddToQueue = () => {
|
||||
const tracks = selectedDbTracks().map(dbTrackToTrack);
|
||||
commitHaptic();
|
||||
playHaptic('confirm');
|
||||
exitSelection();
|
||||
void enqueueEndMany(tracks);
|
||||
};
|
||||
@@ -420,7 +421,7 @@ export default function LibraryScreen() {
|
||||
subtitle={`${selectedIds.size} ${selectedIds.size === 1 ? 'track' : 'tracks'}`}
|
||||
onClose={() => setPlaylistPickerOpen(false)}
|
||||
onAdded={() => {
|
||||
commitHaptic();
|
||||
playHaptic('confirm');
|
||||
exitSelection();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { playTracks, shuffleTracks } from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { artworkThumbUri, artworkUri } from '@/library/artwork';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
|
||||
@@ -58,7 +59,16 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.missingRow}
|
||||
onLongPress={() => {
|
||||
playHaptic('holdAccepted');
|
||||
onLongPress();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={styles.missingMeta}>
|
||||
<Text variant="body" numberOfLines={1} color={colors.textTertiary}>
|
||||
{entry.fallback_title ?? basename(entry.track_path)}
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
@@ -265,7 +265,7 @@ export default function DesktopSyncScreen() {
|
||||
foreground. Manual and desktop-requested syncs always work.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
<HapticSwitch
|
||||
value={autoSyncEnabled}
|
||||
onValueChange={(value) => void setAutoSyncEnabled(value)}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
@@ -181,7 +181,7 @@ export default function LastFmScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
{profile.connected ? (
|
||||
<Switch
|
||||
<HapticSwitch
|
||||
value={profile.enabled}
|
||||
onValueChange={(v) => void setProfileEnabled(profile.id, v)}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
@@ -225,7 +225,7 @@ export default function LastFmScreen() {
|
||||
Submit played tracks + "now playing" to your connected destinations.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
<HapticSwitch
|
||||
value={status?.enabled ?? false}
|
||||
onValueChange={(v) => void setEnabled(v)}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
|
||||
@@ -71,6 +71,12 @@ export default function ExperimentalSettingsScreen() {
|
||||
/>
|
||||
|
||||
<SettingsSectionLabel>DEVELOPER</SettingsSectionLabel>
|
||||
<SettingsNavRow
|
||||
icon="pulse-outline"
|
||||
title="Haptics Lab"
|
||||
subtitle="Audition semantic feedback, device primitives, and signature candidates."
|
||||
onPress={() => router.push('/settings/haptics-lab' as never)}
|
||||
/>
|
||||
<SettingsNavRow
|
||||
icon="refresh-outline"
|
||||
title="Replay onboarding"
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSectionLabel,
|
||||
SettingsSectionScreen,
|
||||
} from '@/components/settings/SettingsSectionScaffold';
|
||||
import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { playHaptic, type HapticEvent } from '@/lib/haptics';
|
||||
import {
|
||||
HAPTIC_RECIPE_SECTIONS,
|
||||
canPlayHapticRecipe,
|
||||
unsupportedRecipePrimitives,
|
||||
} from '@/lib/hapticRecipes';
|
||||
import {
|
||||
AstraHaptics,
|
||||
type HapticCapabilities,
|
||||
type HapticCompositionStep,
|
||||
type HapticPrimitive,
|
||||
} from '../../../modules/astra-haptics';
|
||||
|
||||
const SEMANTIC_EVENTS: {
|
||||
event: HapticEvent;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ event: 'toggleOn', label: 'Toggle on', description: 'A setting enters its active state.' },
|
||||
{ event: 'toggleOff', label: 'Toggle off', description: 'A setting leaves its active state.' },
|
||||
{ event: 'selection', label: 'Selection', description: 'A discrete choice changes.' },
|
||||
{ event: 'frequentStep', label: 'Frequent step', description: 'A repeated row or letter crossing.' },
|
||||
{ event: 'threshold', label: 'Threshold', description: 'A gesture becomes armed.' },
|
||||
{ event: 'action', label: 'Action', description: 'A direct control commits.' },
|
||||
{ event: 'dragStart', label: 'Drag start', description: 'An item is picked up.' },
|
||||
{ event: 'dragEnd', label: 'Drag end', description: 'An item is released.' },
|
||||
{ event: 'confirm', label: 'Confirm', description: 'An operation succeeds.' },
|
||||
{ event: 'reject', label: 'Reject', description: 'An operation is rejected.' },
|
||||
];
|
||||
|
||||
const PRIMITIVES: { primitive: HapticPrimitive; label: string }[] = [
|
||||
{ primitive: 'click', label: 'Click' },
|
||||
{ primitive: 'tick', label: 'Tick' },
|
||||
{ primitive: 'lowTick', label: 'Low tick' },
|
||||
{ primitive: 'thud', label: 'Thud' },
|
||||
{ primitive: 'quickRise', label: 'Quick rise' },
|
||||
{ primitive: 'slowRise', label: 'Slow rise' },
|
||||
{ primitive: 'quickFall', label: 'Quick fall' },
|
||||
{ primitive: 'spin', label: 'Spin' },
|
||||
];
|
||||
|
||||
const SCALES = [0.5, 0.7, 1] as const;
|
||||
|
||||
function yesNo(value: boolean): string {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
export default function HapticsLabScreen() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const [capabilities, setCapabilities] = useState<HapticCapabilities>(() =>
|
||||
AstraHaptics.getCapabilities()
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const refresh = () => {
|
||||
setCapabilities(AstraHaptics.getCapabilities());
|
||||
setStatus(null);
|
||||
};
|
||||
|
||||
const playComposition = (steps: readonly HapticCompositionStep[], label: string) => {
|
||||
const played = AstraHaptics.playComposition(steps.map((step) => ({ ...step })));
|
||||
setStatus(played ? `Played ${label}.` : `${label} could not play on this device.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSectionScreen title="Haptics Lab">
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.intro}>
|
||||
Audition Android's semantic feedback and Astra's shared composition
|
||||
candidates. Timing calibration is recorded; the recipes below are ready for a
|
||||
fresh vote. Custom candidates are not wired into production gestures yet.
|
||||
</Text>
|
||||
|
||||
<SettingsSectionLabel>DEVICE</SettingsSectionLabel>
|
||||
<SettingsCard>
|
||||
<CapabilityRow label="Native module" value={yesNo(capabilities.moduleAvailable)} />
|
||||
<CapabilityRow label="Android API" value={String(capabilities.apiLevel)} />
|
||||
<CapabilityRow label="Vibrator" value={yesNo(capabilities.hasVibrator)} />
|
||||
<CapabilityRow
|
||||
label="Amplitude control"
|
||||
value={yesNo(capabilities.hasAmplitudeControl)}
|
||||
/>
|
||||
<CapabilityRow
|
||||
label="Touch feedback enabled"
|
||||
value={yesNo(capabilities.touchFeedbackEnabled)}
|
||||
/>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.refreshButton}
|
||||
onPress={refresh}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text variant="label" color={colors.accentTextStrong}>Refresh capabilities</Text>
|
||||
</Pressable>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSectionLabel spaced>SEMANTIC VOCABULARY</SettingsSectionLabel>
|
||||
<SettingsCard style={styles.stack}>
|
||||
{SEMANTIC_EVENTS.map(({ event, label, description }) => (
|
||||
<View key={event} style={styles.auditionRow}>
|
||||
<View style={styles.rowCopy}>
|
||||
<Text variant="body">{label}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>{description}</Text>
|
||||
</View>
|
||||
<AuditionButton label="Feel" onPress={() => playHaptic(event)} />
|
||||
</View>
|
||||
))}
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSectionLabel spaced>PRIMITIVES</SettingsSectionLabel>
|
||||
<SettingsCard style={styles.stack}>
|
||||
{PRIMITIVES.map(({ primitive, label }) => {
|
||||
const capability = capabilities.primitives[primitive];
|
||||
return (
|
||||
<View key={primitive} style={styles.primitiveBlock}>
|
||||
<View style={styles.primitiveHeading}>
|
||||
<Text variant="body">{label}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{capability.supported
|
||||
? capability.durationMs > 0
|
||||
? `${capability.durationMs} ms`
|
||||
: 'Supported'
|
||||
: 'Unsupported'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.buttonRow}>
|
||||
{SCALES.map((scale) => (
|
||||
<AuditionButton
|
||||
key={scale}
|
||||
label={scale.toFixed(1)}
|
||||
disabled={!capability.supported || !capabilities.touchFeedbackEnabled}
|
||||
onPress={() =>
|
||||
playComposition([{ primitive, scale, delayMs: 0 }], `${label} ${scale}`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</SettingsCard>
|
||||
|
||||
{HAPTIC_RECIPE_SECTIONS.map((section) => (
|
||||
<View key={section.id} style={styles.recipeSection}>
|
||||
<SettingsSectionLabel spaced>{section.label}</SettingsSectionLabel>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.sectionIntro}>
|
||||
{section.description}
|
||||
</Text>
|
||||
{section.groups.map((group) => {
|
||||
const leadingCandidate = group.candidates.find(
|
||||
(candidate) => candidate.id === group.leadingCandidateId
|
||||
);
|
||||
return (
|
||||
<SettingsCard key={group.id} style={styles.recipeCard}>
|
||||
<Text variant="heading">{group.label}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>{group.description}</Text>
|
||||
{leadingCandidate ? (
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
{section.id === 'timing'
|
||||
? 'Selected calibration'
|
||||
: group.selectionStatus === 'provisional'
|
||||
? 'Provisional choice'
|
||||
: 'Selected candidate'}{' '}
|
||||
· {leadingCandidate.label}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.recipeButtons}>
|
||||
{group.candidates.map((candidate) => {
|
||||
const unsupported = unsupportedRecipePrimitives(
|
||||
candidate.steps,
|
||||
capabilities
|
||||
);
|
||||
const enabled = canPlayHapticRecipe(candidate.steps, capabilities);
|
||||
return (
|
||||
<View key={candidate.id} style={styles.recipeCandidate}>
|
||||
<AuditionButton
|
||||
label={
|
||||
group.id === 'holdAccepted'
|
||||
? `Hold · ${candidate.label}`
|
||||
: candidate.label
|
||||
}
|
||||
disabled={!enabled}
|
||||
wide
|
||||
onPress={
|
||||
group.id === 'holdAccepted'
|
||||
? undefined
|
||||
: () => playComposition(candidate.steps, candidate.label)
|
||||
}
|
||||
onLongPress={
|
||||
group.id === 'holdAccepted'
|
||||
? () => playComposition(candidate.steps, candidate.label)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
{unsupported.length > 0
|
||||
? `Needs ${unsupported.join(', ')}`
|
||||
: capabilities.touchFeedbackEnabled
|
||||
? 'Unavailable'
|
||||
: 'Touch feedback is off'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</SettingsCard>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{status ? (
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.status}>
|
||||
{status}
|
||||
</Text>
|
||||
) : null}
|
||||
</SettingsSectionScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityRow({ label, value }: { label: string; value: string }) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={styles.capabilityRow}>
|
||||
<Text variant="body">{label}</Text>
|
||||
<Text variant="label" color={colors.textSecondary}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditionButton({
|
||||
label,
|
||||
disabled = false,
|
||||
wide = false,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
wide?: boolean;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={500}
|
||||
style={[styles.auditionButton, wide && styles.wideButton, disabled && styles.disabled]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
>
|
||||
<Text variant="label" color={disabled ? colors.textTertiary : colors.accentTextStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
intro: {
|
||||
lineHeight: 18,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
stack: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
capabilityRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
refreshButton: {
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
auditionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
rowCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
primitiveBlock: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
primitiveHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
auditionButton: {
|
||||
minWidth: 62,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
wideButton: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
disabled: {
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
opacity: 0.65,
|
||||
},
|
||||
recipeCard: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
recipeSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
sectionIntro: {
|
||||
lineHeight: 18,
|
||||
},
|
||||
recipeButtons: {
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
recipeCandidate: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
status: {
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user