ALF content language dialog (#9471)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-02-18 16:57:32 +00:00
committed by GitHub
parent bdf143ce79
commit 512ce3d5d0
14 changed files with 170 additions and 659 deletions
@@ -11,11 +11,11 @@ import {
import {atoms as a, useTheme} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {LanguageSelectDialog} from '#/components/dialogs/LanguageSelectDialog'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {PostLanguageSelectDialog} from './PostLanguageSelectDialog'
export function PostLanguageSelect({
currentLanguages: currentLanguagesProp,
@@ -36,6 +36,15 @@ export function PostLanguageSelect({
const currentLanguages =
currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage)
const onSelectLanguages = (languages: string[]) => {
let langsString = languages.join(',')
if (!langsString) {
langsString = langPrefs.primaryLanguage
}
setLangPrefs.setPostLanguage(langsString)
onSelectLanguage?.(langsString)
}
if (
dedupedHistory.length === 1 &&
dedupedHistory[0] === langPrefs.postLanguage
@@ -43,9 +52,15 @@ export function PostLanguageSelect({
return (
<>
<LanguageBtn onPress={languageDialogControl.open} />
<PostLanguageSelectDialog
<LanguageSelectDialog
titleText={<Trans>Choose post languages</Trans>}
subtitleText={
<Trans>Select up to 3 languages used in this post</Trans>
}
control={languageDialogControl}
currentLanguages={currentLanguages}
onSelectLanguages={onSelectLanguages}
maxLanguages={3}
/>
</>
)
@@ -94,10 +109,13 @@ export function PostLanguageSelect({
</Menu.Outer>
</Menu.Root>
<PostLanguageSelectDialog
<LanguageSelectDialog
titleText={<Trans>Choose post languages</Trans>}
subtitleText={<Trans>Select up to 3 languages used in this post</Trans>}
control={languageDialogControl}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
onSelectLanguages={onSelectLanguages}
maxLanguages={3}
/>
</>
)
@@ -1,340 +0,0 @@
import {useCallback, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {languageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {SearchInput} from '#/components/forms/SearchInput'
import * as Toggle from '#/components/forms/Toggle'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
export function PostLanguageSelectDialog({
control,
/**
* Optionally can be passed to show different values than what is saved in
* langPrefs.
*/
currentLanguages,
onSelectLanguage,
}: {
control: Dialog.DialogControlProps
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
}) {
const {height} = useWindowDimensions()
const insets = useSafeAreaInsets()
const renderErrorBoundary = useCallback(
(error: any) => <DialogError details={String(error)} />,
[],
)
return (
<Dialog.Outer
control={control}
nativeOptions={{minHeight: height - insets.top}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<DialogInner
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
/>
</ErrorBoundary>
</Dialog.Outer>
)
}
export function DialogInner({
currentLanguages,
onSelectLanguage,
}: {
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
}) {
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
const allowedLanguages = useMemo(() => {
const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce(
(acc, lang) => {
acc[lang.code2] = lang
return acc
},
{} as Record<string, Language>,
)
return Object.values(uniqueLanguagesMap)
}, [])
const langPrefs = useLanguagePrefs()
const postLanguagesPref =
currentLanguages ?? toPostLanguages(langPrefs.postLanguage)
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
postLanguagesPref || [langPrefs.primaryLanguage],
)
const [search, setSearch] = useState('')
const setLangPrefs = useLanguagePrefsApi()
const t = useTheme()
const {_} = useLingui()
const handleClose = () => {
control.close(() => {
let langsString = checkedLanguagesCode2.join(',')
if (!langsString) {
langsString = langPrefs.primaryLanguage
}
setLangPrefs.setPostLanguage(langsString)
onSelectLanguage?.(langsString)
})
}
// NOTE(@elijaharita): Displayed languages are split into 3 lists for
// ordering.
const displayedLanguages = useMemo(() => {
function mapCode2List(code2List: string[]) {
return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean)
}
// NOTE(@elijaharita): Get recent language codes and map them to language
// objects. Both the user account's saved language history and the current
// checked languages are displayed here.
const recentLanguagesCode2 =
Array.from(
new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]),
).slice(0, 5) || []
const recentLanguages = mapCode2List(recentLanguagesCode2)
// NOTE(@elijaharita): helper functions
const matchesSearch = (lang: Language) =>
lang.name.toLowerCase().includes(search.toLowerCase())
const isChecked = (lang: Language) =>
checkedLanguagesCode2.includes(lang.code2)
const isInRecents = (lang: Language) =>
recentLanguagesCode2.includes(lang.code2)
const checkedRecent = recentLanguages.filter(isChecked)
if (search) {
// NOTE(@elijaharita): if a search is active, we ALWAYS show checked
// items, as well as any items that match the search.
const uncheckedRecent = recentLanguages
.filter(lang => !isChecked(lang))
.filter(matchesSearch)
const unchecked = allowedLanguages.filter(lang => !isChecked(lang))
const all = unchecked
.filter(matchesSearch)
.filter(lang => !isInRecents(lang))
return {
all,
checkedRecent,
uncheckedRecent,
}
} else {
// NOTE(@elijaharita): if no search is active, we show everything.
const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang))
const all = allowedLanguages
.filter(lang => !recentLanguagesCode2.includes(lang.code2))
.filter(lang => !isInRecents(lang))
return {
all,
checkedRecent,
uncheckedRecent,
}
}
}, [
allowedLanguages,
search,
langPrefs.postLanguageHistory,
checkedLanguagesCode2,
])
const listHeader = (
<View
style={[a.pb_xs, t.atoms.bg, IS_NATIVE && a.pt_2xl]}
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View style={[a.flex_row, a.w_full, a.justify_between]}>
<View>
<Text
nativeID="dialog-title"
style={[
t.atoms.text,
a.text_left,
a.font_semi_bold,
a.text_xl,
a.mb_sm,
]}>
<Trans>Choose Post Languages</Trans>
</Text>
<Text
nativeID="dialog-description"
style={[
t.atoms.text_contrast_medium,
a.text_left,
a.text_md,
a.mb_lg,
]}>
<Trans>Select up to 3 languages used in this post</Trans>
</Text>
</View>
{IS_WEB && (
<Button
variant="ghost"
size="small"
color="secondary"
shape="round"
label={_(msg`Close dialog`)}
onPress={handleClose}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</View>
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}>
<SearchInput
value={search}
onChangeText={setSearch}
placeholder={_(msg`Search languages`)}
label={_(msg`Search languages`)}
maxLength={50}
onClearText={() => setSearch('')}
/>
</View>
</View>
)
const isCheckedRecentEmpty =
displayedLanguages.checkedRecent.length > 0 ||
displayedLanguages.uncheckedRecent.length > 0
const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0
const flatListData = [
...(isCheckedRecentEmpty
? [{type: 'header', label: _(msg`Recently used`)}]
: []),
...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})),
...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})),
...(isDisplayedLanguagesEmpty
? []
: [{type: 'header', label: _(msg`All languages`)}]),
...displayedLanguages.all.map(lang => ({type: 'item', lang})),
]
return (
<Toggle.Group
values={checkedLanguagesCode2}
onChange={setCheckedLanguagesCode2}
type="checkbox"
maxSelections={3}
label={_(msg`Select languages`)}
style={web([a.contents])}>
<Dialog.InnerFlatList
data={flatListData}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
contentContainerStyle={[a.gap_0]}
style={[IS_NATIVE && a.px_lg, web({paddingBottom: 120})]}
scrollIndicatorInsets={{top: headerHeight}}
renderItem={({item, index}) => {
if (item.type === 'header') {
return (
<Text
key={index}
style={[
a.px_0,
a.py_md,
a.font_semi_bold,
a.text_xs,
t.atoms.text_contrast_low,
a.pt_3xl,
]}>
{item.label}
</Text>
)
}
const lang = item.lang
return (
<Toggle.Item
key={lang.code2}
name={lang.code2}
label={languageName(lang, langPrefs.appLanguage)}
style={[
t.atoms.border_contrast_low,
a.border_b,
a.rounded_0,
a.px_0,
a.py_md,
]}>
<Toggle.LabelText style={[a.flex_1]}>
{languageName(lang, langPrefs.appLanguage)}
</Toggle.LabelText>
<Toggle.Checkbox />
</Toggle.Item>
)
}}
footer={
<Dialog.FlatListFooter>
<Button
label={_(msg`Close dialog`)}
onPress={handleClose}
color="primary"
size="large">
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Dialog.FlatListFooter>
}
/>
</Toggle.Group>
)
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
return (
<Dialog.ScrollableInner
style={a.gap_md}
label={_(msg`An error has occurred`)}>
<Dialog.Close />
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={() => control.close()}
color="primary"
size="large">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
)
}
-4
View File
@@ -7,7 +7,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useModalControls, useModals} from '#/state/modals'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -44,9 +43,6 @@ export function ModalsContainer() {
if (activeModal?.name === 'user-add-remove-lists') {
snapPoints = UserAddRemoveListsModal.snapPoints
element = <UserAddRemoveListsModal.Component {...activeModal} />
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element = <ContentLanguagesSettingsModal.Component />
} else {
return null
}
-3
View File
@@ -6,7 +6,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Modal as ModalIface} from '#/state/modals'
import {useModalControls, useModals} from '#/state/modals'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as UserAddRemoveLists from './UserAddRemoveLists'
export function ModalsContainer() {
@@ -47,8 +46,6 @@ function Modal({modal}: {modal: ModalIface}) {
let element
if (modal.name === 'user-add-remove-lists') {
element = <UserAddRemoveLists.Component {...modal} />
} else if (modal.name === 'content-languages-settings') {
element = <ContentLanguagesSettingsModal.Component />
} else {
return null
}
@@ -1,64 +0,0 @@
import {Pressable, StyleSheet, Text, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {colors, gradients, s} from '#/lib/styles'
export const ConfirmLanguagesButton = ({
onPress,
extraText,
}: {
onPress: () => void
extraText?: string
}) => {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
return (
<View
style={[
styles.btnContainer,
pal.borderDark,
isMobile && {
paddingBottom: 40,
borderTopWidth: 1,
},
]}>
<Pressable
testID="confirmContentLanguagesBtn"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Confirm content language settings`)}
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Done{extraText}</Trans>
</Text>
</LinearGradient>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
btnContainer: {
paddingTop: 10,
paddingHorizontal: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})
@@ -1,128 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Trans} from '@lingui/macro'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {deviceLanguageCodes} from '#/locale/deviceLocales'
import {languageName} from '#/locale/helpers'
import {useModalControls} from '#/state/modals'
import {
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
import {Text} from '../../util/text/Text'
import {ScrollView} from '../util'
import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
import {LanguageToggle} from './LanguageToggle'
export const snapPoints = ['100%']
export function Component({}: {}) {
const {closeModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const onPressDone = React.useCallback(() => {
closeModal()
}, [closeModal])
const languages = React.useMemo(() => {
const langs = LANGUAGES.filter(
lang =>
!!lang.code2.trim() &&
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
)
// sort so that device & selected languages are on top, then alphabetically
langs.sort((a, b) => {
const hasA =
langPrefs.contentLanguages.includes(a.code2) ||
deviceLanguageCodes.includes(a.code2)
const hasB =
langPrefs.contentLanguages.includes(b.code2) ||
deviceLanguageCodes.includes(b.code2)
if (hasA === hasB) return a.name.localeCompare(b.name)
if (hasA) return -1
return 1
})
return langs
}, [langPrefs])
const onPress = React.useCallback(
(code2: string) => {
setLangPrefs.toggleContentLanguage(code2)
},
[setLangPrefs],
)
return (
<View
testID="contentLanguagesModal"
style={[
pal.view,
styles.container,
// @ts-ignore vh is web only
isMobile
? {
paddingTop: 20,
}
: {
maxHeight: '90vh',
},
]}>
<Text style={[pal.text, styles.title]}>
<Trans>Content Languages</Trans>
</Text>
<Text style={[pal.text, styles.description]}>
<Trans>
Which languages would you like to see in your algorithmic feeds?
</Trans>
</Text>
<Text style={[pal.textLight, styles.description]}>
<Trans>Leave them all unselected to see any language.</Trans>
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => (
<LanguageToggle
key={lang.code2}
code2={lang.code2}
langType="contentLanguages"
name={languageName(lang, langPrefs.appLanguage)}
onPress={() => {
onPress(lang.code2)
}}
/>
))}
<View
style={{
height: isMobile ? 60 : 0,
}}
/>
</ScrollView>
<ConfirmLanguagesButton onPress={onPressDone} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
textAlign: 'center',
fontWeight: '600',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 16,
marginBottom: 10,
},
scrollContainer: {
flex: 1,
paddingHorizontal: 10,
},
})
@@ -1,53 +0,0 @@
import {StyleSheet} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {toPostLanguages, useLanguagePrefs} from '#/state/preferences/languages'
import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
export function LanguageToggle({
code2,
name,
onPress,
langType,
}: {
code2: string
name: string
onPress: () => void
langType: 'contentLanguages' | 'postLanguages'
}) {
const pal = usePalette('default')
const langPrefs = useLanguagePrefs()
const values =
langType === 'contentLanguages'
? langPrefs.contentLanguages
: toPostLanguages(langPrefs.postLanguage)
const isSelected = values.includes(code2)
// enforce a max of 3 selections for post languages
let isDisabled = false
if (langType === 'postLanguages' && values.length >= 3 && !isSelected) {
isDisabled = true
}
return (
<ToggleButton
label={name}
isSelected={isSelected}
onPress={isDisabled ? undefined : onPress}
style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
/>
)
}
const styles = StyleSheet.create({
languageToggle: {
borderTopWidth: 1,
borderRadius: 0,
paddingHorizontal: 6,
paddingVertical: 12,
},
dimmed: {
opacity: 0.5,
},
})
-193
View File
@@ -1,193 +0,0 @@
import {
type StyleProp,
StyleSheet,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import {choose} from '#/lib/functions'
import {colors} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {type TypographyVariant} from '#/lib/ThemeContext'
import {Text} from '../text/Text'
import {Button, type ButtonType} from './Button'
/**
* @deprecated use Toggle from `#/components/form/Toggle.tsx` instead
*/
export function ToggleButton({
testID,
type = 'default-light',
label,
isSelected,
style,
labelType,
onPress,
}: {
testID?: string
type?: ButtonType
label: string
isSelected: boolean
style?: StyleProp<ViewStyle>
labelType?: TypographyVariant
onPress?: () => void
}) {
const theme = useTheme()
const circleStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
primary: {
borderColor: theme.palette.primary.text,
},
secondary: {
borderColor: theme.palette.secondary.text,
},
inverted: {
borderColor: theme.palette.inverted.text,
},
'primary-outline': {
borderColor: theme.palette.primary.border,
},
'secondary-outline': {
borderColor: theme.palette.secondary.border,
},
'primary-light': {
borderColor: theme.palette.primary.border,
},
'secondary-light': {
borderColor: theme.palette.secondary.border,
},
default: {
borderColor: theme.palette.default.border,
},
'default-light': {
borderColor: theme.palette.default.border,
},
})
const circleFillStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(
type,
{
primary: {
backgroundColor: theme.palette.primary.text,
opacity: isSelected ? 1 : 0.33,
},
secondary: {
backgroundColor: theme.palette.secondary.text,
opacity: isSelected ? 1 : 0.33,
},
inverted: {
backgroundColor: theme.palette.inverted.text,
opacity: isSelected ? 1 : 0.33,
},
'primary-outline': {
backgroundColor: theme.palette.primary.background,
opacity: isSelected ? 1 : 0.5,
},
'secondary-outline': {
backgroundColor: theme.palette.secondary.background,
opacity: isSelected ? 1 : 0.5,
},
'primary-light': {
backgroundColor: theme.palette.primary.background,
opacity: isSelected ? 1 : 0.5,
},
'secondary-light': {
backgroundColor: theme.palette.secondary.background,
opacity: isSelected ? 1 : 0.5,
},
default: {
backgroundColor: isSelected
? theme.palette.primary.background
: colors.gray3,
},
'default-light': {
backgroundColor: isSelected
? theme.palette.primary.background
: colors.gray3,
},
},
)
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
primary: {
color: theme.palette.primary.text,
fontWeight: theme.palette.primary.isLowContrast ? '600' : undefined,
},
secondary: {
color: theme.palette.secondary.text,
fontWeight: theme.palette.secondary.isLowContrast ? '600' : undefined,
},
inverted: {
color: theme.palette.inverted.text,
fontWeight: theme.palette.inverted.isLowContrast ? '600' : undefined,
},
'primary-outline': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '600' : undefined,
},
'secondary-outline': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '600' : undefined,
},
'primary-light': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '600' : undefined,
},
'secondary-light': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '600' : undefined,
},
default: {
color: theme.palette.default.text,
fontWeight: theme.palette.default.isLowContrast ? '600' : undefined,
},
'default-light': {
color: theme.palette.default.text,
fontWeight: theme.palette.default.isLowContrast ? '600' : undefined,
},
})
return (
<Button testID={testID} type={type} onPress={onPress} style={style}>
<View style={styles.outer}>
<View style={[circleStyle, styles.circle]}>
<View
style={[
circleFillStyle,
styles.circleFill,
isSelected ? styles.circleFillSelected : undefined,
]}
/>
</View>
{label === '' ? null : (
<Text type={labelType || 'button'} style={[labelStyle, styles.label]}>
{label}
</Text>
)}
</View>
</Button>
)
}
const styles = StyleSheet.create({
outer: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
circle: {
width: 42,
height: 26,
borderRadius: 15,
padding: 3,
borderWidth: 2,
},
circleFill: {
width: 16,
height: 16,
borderRadius: 10,
},
circleFillSelected: {
marginLeft: 16,
},
label: {
flex: 1,
},
})