diff options
Diffstat (limited to 'src/screens/Settings/AppIconSettings')
8 files changed, 479 insertions, 0 deletions
diff --git a/src/screens/Settings/AppIconSettings/AppIconImage.tsx b/src/screens/Settings/AppIconSettings/AppIconImage.tsx new file mode 100644 index 000000000..e81d5d0d5 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/AppIconImage.tsx @@ -0,0 +1,33 @@ +import {Image} from 'expo-image' + +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' +import {atoms as a, platform, useTheme} from '#/alf' + +export function AppIconImage({ + icon, + size = 50, +}: { + icon: AppIconSet + size: number +}) { + const t = useTheme() + return ( + <Image + source={platform({ + ios: icon.iosImage(), + android: icon.androidImage(), + })} + style={[ + {width: size, height: size}, + platform({ + ios: {borderRadius: size / 5}, + android: a.rounded_full, + }), + a.curve_continuous, + t.atoms.border_contrast_medium, + a.border, + ]} + accessibilityIgnoresInvertColors + /> + ) +} diff --git a/src/screens/Settings/AppIconSettings/SettingsListItem.tsx b/src/screens/Settings/AppIconSettings/SettingsListItem.tsx new file mode 100644 index 000000000..add87b1d7 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/SettingsListItem.tsx @@ -0,0 +1,29 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {AppIconImage} from '#/screens/Settings/AppIconSettings/AppIconImage' +import {useCurrentAppIcon} from '#/screens/Settings/AppIconSettings/useCurrentAppIcon' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes' + +export function SettingsListItem() { + const {_} = useLingui() + const icon = useCurrentAppIcon() + + return ( + <SettingsList.LinkItem + to="/settings/app-icon" + label={_(msg`App Icon`)} + contentContainerStyle={[a.align_start]}> + <SettingsList.ItemIcon icon={Shapes} /> + <View style={[a.flex_1]}> + <SettingsList.ItemText style={[a.pt_xs, a.pb_md]}> + <Trans>App Icon</Trans> + </SettingsList.ItemText> + <AppIconImage icon={icon} size={60} /> + </View> + </SettingsList.LinkItem> + ) +} diff --git a/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx new file mode 100644 index 000000000..c7707d23f --- /dev/null +++ b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx @@ -0,0 +1 @@ +export function SettingsListItem() {} diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx new file mode 100644 index 000000000..0fefca29b --- /dev/null +++ b/src/screens/Settings/AppIconSettings/index.tsx @@ -0,0 +1,244 @@ +import {useState} from 'react' +import {Alert, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {isAndroid} from '#/platform/detection' +import {useSession} from '#/state/session' +import {AppIconImage} from '#/screens/Settings/AppIconSettings/AppIconImage' +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' +import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppIconSettings'> +export function AppIconSettingsScreen({}: Props) { + const t = useTheme() + const {_} = useLingui() + const sets = useAppIconSets() + const {currentAccount} = useSession() + const [currentAppIcon, setCurrentAppIcon] = useState(() => + getAppIconName(DynamicAppIcon.getAppIcon()), + ) + + const onSetAppIcon = (icon: string) => { + if (isAndroid) { + const next = + sets.defaults.find(i => i.id === icon) ?? + sets.core.find(i => i.id === icon) + Alert.alert( + next + ? _(msg`Change app icon to "${next.name}"`) + : _(msg`Change app icon`), + // to determine - can we stop this happening? -sfn + _(msg`The app will be restarted`), + [ + { + text: _(msg`Cancel`), + style: 'cancel', + }, + { + text: _(msg`OK`), + onPress: () => { + setCurrentAppIcon(setAppIcon(icon)) + }, + style: 'default', + }, + ], + ) + } else { + setCurrentAppIcon(setAppIcon(icon)) + } + } + + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>App Icon</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + + <Layout.Content contentContainerStyle={[a.p_lg]}> + <Group + label={_(msg`Default icons`)} + value={currentAppIcon} + onChange={onSetAppIcon}> + {sets.defaults.map((icon, i) => ( + <Row + key={icon.id} + icon={icon} + isEnd={i === sets.defaults.length - 1}> + <AppIcon icon={icon} key={icon.id} size={40} /> + <RowText>{icon.name}</RowText> + </Row> + ))} + </Group> + + {DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( + <> + <Text + style={[ + a.text_md, + a.mt_xl, + a.mb_sm, + a.font_bold, + t.atoms.text_contrast_medium, + ]}> + <Trans>Bluesky+</Trans> + </Text> + <Group + label={_(msg`Bluesky+ icons`)} + value={currentAppIcon} + onChange={onSetAppIcon}> + {sets.core.map((icon, i) => ( + <Row + key={icon.id} + icon={icon} + isEnd={i === sets.core.length - 1}> + <AppIcon icon={icon} key={icon.id} size={40} /> + <RowText>{icon.name}</RowText> + </Row> + ))} + </Group> + </> + )} + </Layout.Content> + </Layout.Screen> + ) +} + +function setAppIcon(icon: string) { + if (icon === 'default_light') { + return getAppIconName(DynamicAppIcon.setAppIcon(null)) + } else { + return getAppIconName(DynamicAppIcon.setAppIcon(icon)) + } +} + +function getAppIconName(icon: string | false) { + if (!icon || icon === 'DEFAULT') { + return 'default_light' + } else { + return icon + } +} + +function Group({ + children, + label, + value, + onChange, +}: { + children: React.ReactNode + label: string + value: string + onChange: (value: string) => void +}) { + return ( + <Toggle.Group + type="radio" + label={label} + values={[value]} + maxSelections={1} + onChange={vals => { + if (vals[0]) onChange(vals[0]) + }}> + <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}> + {children} + </View> + </Toggle.Group> + ) +} + +function Row({ + icon, + children, + isEnd, +}: { + icon: AppIconSet + children: React.ReactNode + isEnd: boolean +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Toggle.Item label={_(msg`Set app icon to ${icon.name}`)} name={icon.id}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.p_md, + a.flex_row, + a.gap_md, + a.align_center, + t.atoms.bg_contrast_25, + (hovered || pressed) && t.atoms.bg_contrast_50, + t.atoms.border_contrast_high, + !isEnd && a.border_b, + ]}> + {children} + <Toggle.Radio /> + </View> + )} + </Toggle.Item> + ) +} + +function RowText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <Text + style={[a.text_md, a.font_bold, a.flex_1, t.atoms.text_contrast_medium]} + emoji> + {children} + </Text> + ) +} + +function AppIcon({icon, size = 50}: {icon: AppIconSet; size: number}) { + const {_} = useLingui() + return ( + <PressableScale + accessibilityLabel={icon.name} + accessibilityHint={_(msg`Tap to change app icon`)} + targetScale={0.95} + onPress={() => { + if (isAndroid) { + Alert.alert( + _(msg`Change app icon to "${icon.name}"`), + _(msg`The app will be restarted`), + [ + { + text: _(msg`Cancel`), + style: 'cancel', + }, + { + text: _(msg`OK`), + onPress: () => { + DynamicAppIcon.setAppIcon(icon.id) + }, + style: 'default', + }, + ], + ) + } else { + DynamicAppIcon.setAppIcon(icon.id) + } + }}> + <AppIconImage icon={icon} size={size} /> + </PressableScale> + ) +} diff --git a/src/screens/Settings/AppIconSettings/index.web.tsx b/src/screens/Settings/AppIconSettings/index.web.tsx new file mode 100644 index 000000000..d8c4d9bea --- /dev/null +++ b/src/screens/Settings/AppIconSettings/index.web.tsx @@ -0,0 +1,3 @@ +export function AppIconSettingsScreen() { + throw new Error('Not supported on web') +} diff --git a/src/screens/Settings/AppIconSettings/types.ts b/src/screens/Settings/AppIconSettings/types.ts new file mode 100644 index 000000000..5010f6f02 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/types.ts @@ -0,0 +1,8 @@ +import {ImageSourcePropType} from 'react-native' + +export type AppIconSet = { + id: string + name: string + iosImage: () => ImageSourcePropType + androidImage: () => ImageSourcePropType +} diff --git a/src/screens/Settings/AppIconSettings/useAppIconSets.ts b/src/screens/Settings/AppIconSettings/useAppIconSets.ts new file mode 100644 index 000000000..47fc5a15f --- /dev/null +++ b/src/screens/Settings/AppIconSettings/useAppIconSets.ts @@ -0,0 +1,134 @@ +import {useMemo} from 'react' +import {useLingui} from '@lingui/react' + +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' + +export function useAppIconSets() { + const {_} = useLingui() + + return useMemo(() => { + const defaults = [ + { + id: 'default_light', + name: _('Light'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_default_light.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_default_light.png`) + }, + }, + { + id: 'default_dark', + name: _('Dark'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_default_dark.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_default_dark.png`) + }, + }, + ] satisfies AppIconSet[] + + /** + * Bluesky+ + */ + const core = [ + { + id: 'core_aurora', + name: _('Aurora'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_aurora.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_aurora.png`) + }, + }, + // { + // id: 'core_bonfire', + // name: _('Bonfire'), + // iosImage: () => { + // return require(`../../../../assets/app-icons/ios_icon_core_bonfire.png`) + // }, + // androidImage: () => { + // return require(`../../../../assets/app-icons/android_icon_core_bonfire.png`) + // }, + // }, + { + id: 'core_sunrise', + name: _('Sunrise'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_sunrise.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_sunrise.png`) + }, + }, + { + id: 'core_sunset', + name: _('Sunset'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_sunset.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_sunset.png`) + }, + }, + { + id: 'core_midnight', + name: _('Midnight'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_midnight.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_midnight.png`) + }, + }, + { + id: 'core_flat_blue', + name: _('Flat Blue'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_blue.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_blue.png`) + }, + }, + { + id: 'core_flat_white', + name: _('Flat White'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_white.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_white.png`) + }, + }, + { + id: 'core_flat_black', + name: _('Flat Black'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_black.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_black.png`) + }, + }, + { + id: 'core_classic', + name: _('Bluesky Classicâ„¢'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_classic.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_classic.png`) + }, + }, + ] satisfies AppIconSet[] + + return { + defaults, + core, + } + }, [_]) +} diff --git a/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts new file mode 100644 index 000000000..4bc9b665a --- /dev/null +++ b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts @@ -0,0 +1,27 @@ +import {useCallback, useMemo, useState} from 'react' +import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import {useFocusEffect} from '@react-navigation/native' + +import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' + +export function useCurrentAppIcon() { + const appIconSets = useAppIconSets() + const [currentAppIcon, setCurrentAppIcon] = useState(() => + DynamicAppIcon.getAppIcon(), + ) + + // refresh current icon when screen is focused + useFocusEffect( + useCallback(() => { + setCurrentAppIcon(DynamicAppIcon.getAppIcon()) + }, []), + ) + + return useMemo(() => { + return ( + appIconSets.defaults.find(i => i.id === currentAppIcon) ?? + appIconSets.core.find(i => i.id === currentAppIcon) ?? + appIconSets.defaults[0] + ) + }, [appIconSets, currentAppIcon]) +} |