feat: 增加了基础的设置相关内容
包括 子组件input、switch、alert、line、link、group 包括 实现 SettingStore 状态管理 (MobX) 支持持久化 包括 类型相关内容声明
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "yourprojectsname",
|
||||
"slug": "yourprojectsname",
|
||||
"scheme": "yourprojectsname",
|
||||
"version": "1.0.0",
|
||||
"name": "flexlark-template",
|
||||
"slug": "flexlark-template",
|
||||
"scheme": "flexlark-template",
|
||||
"version": "dev 0.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -25,7 +25,7 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
},
|
||||
"package": "com.yourprojectsname.app"
|
||||
"package": "com.flexlark.template"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
|
||||
19
apps/expo/app/setting/index.tsx
Normal file
19
apps/expo/app/setting/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SettingsScreen } from 'app/features/setting/screen'
|
||||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function Screen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Setting',
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_right',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
/>
|
||||
<SettingsScreen />
|
||||
</>
|
||||
)
|
||||
}
|
||||
167
apps/expo/themes/basic.ts
Normal file
167
apps/expo/themes/basic.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createThemes, defaultComponentThemes } from '@tamagui/theme-builder'
|
||||
import * as Colors from '@tamagui/colors'
|
||||
|
||||
const darkPalette = [
|
||||
'hsla(165, 87%, 1%, 1)',
|
||||
'hsla(164, 86%, 6%, 1)',
|
||||
'hsla(162, 84%, 12%, 1)',
|
||||
'hsla(161, 83%, 17%, 1)',
|
||||
'hsla(159, 82%, 23%, 1)',
|
||||
'hsla(158, 80%, 28%, 1)',
|
||||
'hsla(156, 79%, 34%, 1)',
|
||||
'hsla(155, 78%, 39%, 1)',
|
||||
'hsla(153, 76%, 45%, 1)',
|
||||
'hsla(152, 75%, 50%, 1)',
|
||||
'hsla(0, 15%, 93%, 1)',
|
||||
'hsla(0, 15%, 99%, 1)',
|
||||
]
|
||||
const lightPalette = [
|
||||
'hsla(165, 87%, 99%, 1)',
|
||||
'hsla(164, 86%, 94%, 1)',
|
||||
'hsla(162, 84%, 88%, 1)',
|
||||
'hsla(161, 83%, 83%, 1)',
|
||||
'hsla(159, 82%, 77%, 1)',
|
||||
'hsla(158, 80%, 72%, 1)',
|
||||
'hsla(156, 79%, 66%, 1)',
|
||||
'hsla(155, 78%, 61%, 1)',
|
||||
'hsla(153, 76%, 55%, 1)',
|
||||
'hsla(152, 75%, 50%, 1)',
|
||||
'hsla(0, 15%, 15%, 1)',
|
||||
'hsla(0, 15%, 1%, 1)',
|
||||
]
|
||||
|
||||
const lightShadows = {
|
||||
shadow1: 'rgba(0,0,0,0.04)',
|
||||
shadow2: 'rgba(0,0,0,0.08)',
|
||||
shadow3: 'rgba(0,0,0,0.16)',
|
||||
shadow4: 'rgba(0,0,0,0.24)',
|
||||
shadow5: 'rgba(0,0,0,0.32)',
|
||||
shadow6: 'rgba(0,0,0,0.4)',
|
||||
}
|
||||
|
||||
const darkShadows = {
|
||||
shadow1: 'rgba(0,0,0,0.2)',
|
||||
shadow2: 'rgba(0,0,0,0.3)',
|
||||
shadow3: 'rgba(0,0,0,0.4)',
|
||||
shadow4: 'rgba(0,0,0,0.5)',
|
||||
shadow5: 'rgba(0,0,0,0.6)',
|
||||
shadow6: 'rgba(0,0,0,0.7)',
|
||||
}
|
||||
|
||||
// we're adding some example sub-themes for you to show how they are done, "success" "warning", "error":
|
||||
|
||||
const builtThemes = createThemes({
|
||||
componentThemes: defaultComponentThemes,
|
||||
|
||||
base: {
|
||||
palette: {
|
||||
dark: darkPalette,
|
||||
light: lightPalette,
|
||||
},
|
||||
|
||||
extra: {
|
||||
light: {
|
||||
...Colors.green,
|
||||
...Colors.red,
|
||||
...Colors.yellow,
|
||||
...lightShadows,
|
||||
shadowColor: lightShadows.shadow1,
|
||||
},
|
||||
dark: {
|
||||
...Colors.greenDark,
|
||||
...Colors.redDark,
|
||||
...Colors.yellowDark,
|
||||
...darkShadows,
|
||||
shadowColor: darkShadows.shadow1,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
accent: {
|
||||
palette: {
|
||||
dark: [
|
||||
'hsla(141, 52%, 35%, 1)',
|
||||
'hsla(153, 52%, 38%, 1)',
|
||||
'hsla(165, 52%, 41%, 1)',
|
||||
'hsla(177, 51%, 43%, 1)',
|
||||
'hsla(189, 51%, 46%, 1)',
|
||||
'hsla(202, 51%, 49%, 1)',
|
||||
'hsla(214, 51%, 52%, 1)',
|
||||
'hsla(226, 50%, 54%, 1)',
|
||||
'hsla(238, 50%, 57%, 1)',
|
||||
'hsla(250, 50%, 60%, 1)',
|
||||
'hsla(250, 50%, 90%, 1)',
|
||||
'hsla(250, 50%, 95%, 1)',
|
||||
],
|
||||
light: [
|
||||
'hsla(141, 52%, 40%, 1)',
|
||||
'hsla(153, 52%, 43%, 1)',
|
||||
'hsla(165, 52%, 46%, 1)',
|
||||
'hsla(177, 51%, 48%, 1)',
|
||||
'hsla(189, 51%, 51%, 1)',
|
||||
'hsla(202, 51%, 54%, 1)',
|
||||
'hsla(214, 51%, 57%, 1)',
|
||||
'hsla(226, 50%, 59%, 1)',
|
||||
'hsla(238, 50%, 62%, 1)',
|
||||
'hsla(250, 50%, 65%, 1)',
|
||||
'hsla(250, 50%, 95%, 1)',
|
||||
'hsla(250, 50%, 95%, 1)',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
childrenThemes: {
|
||||
warning: {
|
||||
palette: {
|
||||
dark: Object.values(Colors.yellowDark),
|
||||
light: Object.values(Colors.yellow),
|
||||
},
|
||||
},
|
||||
|
||||
error: {
|
||||
palette: {
|
||||
dark: Object.values(Colors.redDark),
|
||||
light: Object.values(Colors.red),
|
||||
},
|
||||
},
|
||||
|
||||
success: {
|
||||
palette: {
|
||||
dark: Object.values(Colors.greenDark),
|
||||
light: Object.values(Colors.green),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// optionally add more, can pass palette or template
|
||||
|
||||
// grandChildrenThemes: {
|
||||
// alt1: {
|
||||
// template: 'alt1',
|
||||
// },
|
||||
// alt2: {
|
||||
// template: 'alt2',
|
||||
// },
|
||||
// surface1: {
|
||||
// template: 'surface1',
|
||||
// },
|
||||
// surface2: {
|
||||
// template: 'surface2',
|
||||
// },
|
||||
// surface3: {
|
||||
// template: 'surface3',
|
||||
// },
|
||||
// },
|
||||
})
|
||||
|
||||
export type Themes = typeof builtThemes
|
||||
|
||||
// the process.env conditional here is optional but saves web client-side bundle
|
||||
// size by leaving out themes JS. tamagui automatically hydrates themes from CSS
|
||||
// back into JS for you, and the bundler plugins set TAMAGUI_ENVIRONMENT. so
|
||||
// long as you are using the Vite, Next, Webpack plugins this should just work,
|
||||
// but if not you can just export builtThemes directly as themes:
|
||||
export const basicThemes: Themes =
|
||||
process.env.TAMAGUI_ENVIRONMENT === 'client' && process.env.NODE_ENV === 'production'
|
||||
? ({} as any)
|
||||
: (builtThemes as any)
|
||||
17576
package-lock.json
generated
Normal file
17576
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -18,6 +18,7 @@
|
||||
"web:prod:serve": "yarn workspace next-app serve",
|
||||
"postinstall": "yarn check-tamagui && yarn build",
|
||||
"build": "yarn workspaces foreach --all --exclude next-app run build",
|
||||
"release:android": "cd apps/expo && yarn prebuild && cd android && ./gradlew assembleRelease",
|
||||
"upgrade:tamagui": "yarn up '*tamagui*'@latest '@tamagui/*'@latest",
|
||||
"upgrade:tamagui:canary": "yarn up '*tamagui*'@canary '@tamagui/*'@canary",
|
||||
"check-tamagui": "tamagui check",
|
||||
@@ -34,12 +35,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.6",
|
||||
"@esdora/kit": "^0.2.0",
|
||||
"@tamagui/cli": "^1.132.18",
|
||||
"check-dependency-version-consistency": "^4.1.0",
|
||||
"eslint": "^9.3.0",
|
||||
"expo-sqlite": "~15.2.14",
|
||||
"husky": "^9.1.6",
|
||||
"mobx": "^6.13.7",
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-sqlite-storage": "^6.0.1",
|
||||
"turbo": "^1.13.4",
|
||||
"typescript": "~5.8.3",
|
||||
"ultra-runner": "^3.10.5",
|
||||
@@ -51,6 +58,9 @@
|
||||
"npm": "10.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.3"
|
||||
"@biomejs/biome": "^1.9.3",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react-native-sqlite-storage": "^6",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SwitchThemeButton,
|
||||
useToastController,
|
||||
XStack,
|
||||
YStack
|
||||
YStack,
|
||||
} from '@my/ui'
|
||||
import { ChevronDown, ChevronUp } from '@tamagui/lucide-icons'
|
||||
import { useState } from 'react'
|
||||
@@ -21,9 +21,12 @@ export function HomeScreen({ pagesMode = false }: { pagesMode?: boolean }) {
|
||||
const linkProps = useLink({
|
||||
href: `${linkTarget}/nate`,
|
||||
})
|
||||
const linkSettingProps = useLink({
|
||||
href: '/setting',
|
||||
})
|
||||
|
||||
return (
|
||||
<YStack flex={1} justify="center" items="center" gap="$8" p="$4" bg="$background">
|
||||
<YStack flex={1} justify="center" items="center" gap="$8" p="$4" bg="$background">
|
||||
<XStack
|
||||
position="absolute"
|
||||
width="100%"
|
||||
@@ -56,6 +59,7 @@ export function HomeScreen({ pagesMode = false }: { pagesMode?: boolean }) {
|
||||
</YStack>
|
||||
|
||||
<Button {...linkProps}>Link to user</Button>
|
||||
<Button {...linkSettingProps}>Link to setting</Button>
|
||||
|
||||
<SheetDemo />
|
||||
</YStack>
|
||||
|
||||
67
packages/app/features/setting/screen.tsx
Normal file
67
packages/app/features/setting/screen.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { View, Text } from 'react-native'
|
||||
import { Settings } from '@my/ui'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
|
||||
export function SettingsScreen() {
|
||||
const appName = 'flexlark'
|
||||
const version = 'dev 0.0.0'
|
||||
const items: SettingsItemProps[] = [
|
||||
{
|
||||
id: 'notification',
|
||||
label: '通知',
|
||||
description: '通知内有关于通知的相关设置',
|
||||
type: 'group',
|
||||
options: {
|
||||
children: [
|
||||
{
|
||||
id: 'enableNotification',
|
||||
label: '是否通知',
|
||||
description: '开启后将收到系统通知',
|
||||
type: 'switch',
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
id: 'notificationContent',
|
||||
label: '通知内容',
|
||||
description: '设置通知的内容',
|
||||
type: 'input',
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
type: 'group',
|
||||
label: '关于',
|
||||
options: {
|
||||
children: [
|
||||
{
|
||||
id: 'about-flexlark',
|
||||
label: `关于灵动云雀`,
|
||||
description: `我们是什么?`,
|
||||
type: 'link',
|
||||
options: {
|
||||
url: 'https://flexlark.org/',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'about-app',
|
||||
label: `关于${appName}`,
|
||||
description: `当前应用正在激烈开发中`,
|
||||
type: 'alert',
|
||||
options: {
|
||||
message: `版本号:${version}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Settings items={items} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { defaultConfig } from '@tamagui/config/v4'
|
||||
import { createTamagui } from 'tamagui'
|
||||
import { bodyFont, headingFont } from './fonts'
|
||||
import { animations } from './animations'
|
||||
|
||||
import { basicThemes } from 'apps/expo/themes/basic'
|
||||
export const config = createTamagui({
|
||||
...defaultConfig,
|
||||
animations,
|
||||
@@ -13,5 +13,6 @@ export const config = createTamagui({
|
||||
settings:{
|
||||
...defaultConfig.settings,
|
||||
onlyAllowShorthands: false
|
||||
}
|
||||
},
|
||||
basicThemes
|
||||
})
|
||||
|
||||
28
packages/decorators/package.json
Normal file
28
packages/decorators/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@my/decorators",
|
||||
"version": "0.0.1",
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"private": true,
|
||||
"types": "./src",
|
||||
"main": "src/index.ts",
|
||||
"files": [
|
||||
"types",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tamagui-build --skip-types",
|
||||
"watch": "tamagui-build --skip-types --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tamagui/animations-react-native": "^1.132.18",
|
||||
"@tamagui/font-inter": "^1.132.18",
|
||||
"@tamagui/shorthands": "^1.132.18",
|
||||
"@tamagui/themes": "^1.132.18",
|
||||
"tamagui": "^1.132.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tamagui/build": "^1.132.18"
|
||||
}
|
||||
}
|
||||
16
packages/decorators/src/index.ts
Normal file
16
packages/decorators/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { debounce } from 'lodash'
|
||||
import { createSafe } from '@esdora/kit'
|
||||
|
||||
const safe = createSafe((err) => {
|
||||
console.log(`发生错误: ${err}`)
|
||||
})
|
||||
|
||||
export function Debounce(ms: number) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
descriptor.value = debounce(descriptor.value, ms)
|
||||
}
|
||||
}
|
||||
|
||||
export function Safe(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
descriptor.value = safe(descriptor.value)
|
||||
}
|
||||
7
packages/decorators/src/types.d.ts
vendored
Normal file
7
packages/decorators/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { config } from '@my/config'
|
||||
|
||||
export type Conf = typeof config
|
||||
|
||||
declare module 'tamagui' {
|
||||
interface TamaguiCustomConfig extends Conf {}
|
||||
}
|
||||
13
packages/decorators/tsconfig.json
Normal file
13
packages/decorators/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../config/src/tamagui.config.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"references": []
|
||||
}
|
||||
4
packages/ui/src/Settings/index.ts
Normal file
4
packages/ui/src/Settings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './src/Settings'
|
||||
export * from './src/SettingsItem'
|
||||
export * from './types/Settings.types'
|
||||
export * from './types/SettingsItem.types'
|
||||
13
packages/ui/src/Settings/src/Settings.tsx
Normal file
13
packages/ui/src/Settings/src/Settings.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { YGroup } from 'tamagui'
|
||||
import { SettingsItem } from './SettingsItem'
|
||||
import { SettingsProps } from '../types/Settings.types'
|
||||
export function Settings(props: SettingsProps) {
|
||||
const { items } = props
|
||||
return (
|
||||
<YGroup className="setting">
|
||||
{items?.map((item) => (
|
||||
<SettingsItem key={item.id} {...item} />
|
||||
))}
|
||||
</YGroup>
|
||||
)
|
||||
}
|
||||
42
packages/ui/src/Settings/src/SettingsItem.tsx
Normal file
42
packages/ui/src/Settings/src/SettingsItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ElementType } from 'react'
|
||||
import type { SettingsItemProps } from '../types/SettingsItem.types'
|
||||
import { SettingsAlert } from './items/SettingsAlert'
|
||||
import { SettingsColor } from './items/SettingsColor'
|
||||
import { SettingsInput } from './items/SettingsInput'
|
||||
import { SettingsSelect } from './items/SettingsSelect'
|
||||
import { SettingsSwitch } from './items/SettingsSwitch'
|
||||
import { SettingsLine } from './items/SettingsLine'
|
||||
import { SettingsPage } from './items/SettingsPage'
|
||||
import { SettingsGroup } from './items/SettingsGroup'
|
||||
import { SettingsLink } from './items/SettingsLink'
|
||||
|
||||
export function SettingsItem(props: SettingsItemProps) {
|
||||
const { label, description, type } = props
|
||||
const Component = settingsItemRander(type)
|
||||
return <Component {...props} />
|
||||
}
|
||||
|
||||
type settingsItemRanderType = (type: SettingsItemProps['type']) => ElementType
|
||||
const settingsItemRander: settingsItemRanderType = (type) => {
|
||||
if (type === 'switch') {
|
||||
return SettingsSwitch
|
||||
} else if (type === 'select') {
|
||||
return SettingsSelect
|
||||
} else if (type === 'input') {
|
||||
return SettingsInput
|
||||
} else if (type === 'color') {
|
||||
return SettingsColor
|
||||
} else if (type === 'alert') {
|
||||
return SettingsAlert
|
||||
} else if (type === 'line') {
|
||||
return SettingsLine
|
||||
} else if (type === 'page') {
|
||||
return SettingsPage
|
||||
} else if (type === 'group') {
|
||||
return SettingsGroup
|
||||
} else if (type === 'link') {
|
||||
return SettingsLink
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
62
packages/ui/src/Settings/src/items/SettingsAlert.tsx
Normal file
62
packages/ui/src/Settings/src/items/SettingsAlert.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ListItem, Toast, useToastController, useToastState, YStack } from '@my/ui'
|
||||
import type { SettingsAlertOption, SettingsItemProps } from '@my/ui'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SettingsAlert = (props: SettingsItemProps<'alert'>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
const toast = useToastController()
|
||||
|
||||
const doAlert = useCallback(() => {
|
||||
const { type, message = '', description = '' } = options
|
||||
toast.show(message, {
|
||||
message: description,
|
||||
native: true,
|
||||
type,
|
||||
})
|
||||
}, [options])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CurrentToast />
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={label}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
onPress={doAlert}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CurrentToast = () => {
|
||||
const currentToast = useToastState()
|
||||
|
||||
if (!currentToast || currentToast.isHandledNatively) return null
|
||||
|
||||
return (
|
||||
<Toast
|
||||
animation="100ms"
|
||||
key={currentToast.id}
|
||||
duration={currentToast.duration}
|
||||
enterStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
exitStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
transform={[{ translateY: 0 }]}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
viewportName={currentToast.viewportName}
|
||||
>
|
||||
<YStack>
|
||||
<Toast.Title>{currentToast.title}</Toast.Title>
|
||||
{!!currentToast.message && <Toast.Description>{currentToast.message}</Toast.Description>}
|
||||
</YStack>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
62
packages/ui/src/Settings/src/items/SettingsColor.tsx
Normal file
62
packages/ui/src/Settings/src/items/SettingsColor.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ListItem, Toast, useToastController, useToastState, YStack } from '@my/ui'
|
||||
import type { SettingsAlertOption, SettingsItemProps } from '@my/ui'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SettingsColor = (props: SettingsItemProps<'alert'>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
const toast = useToastController()
|
||||
|
||||
const doAlert = useCallback(() => {
|
||||
const { type, message = '', description = '' } = options
|
||||
toast.show(message, {
|
||||
message: description,
|
||||
type,
|
||||
demo: true,
|
||||
})
|
||||
}, [options])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CurrentToast />
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={label}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
onPress={disabled ? undefined : () => doAlert()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CurrentToast = () => {
|
||||
const currentToast = useToastState()
|
||||
|
||||
if (!currentToast || currentToast.isHandledNatively) return null
|
||||
|
||||
return (
|
||||
<Toast
|
||||
animation="100ms"
|
||||
key={currentToast.id}
|
||||
duration={currentToast.duration}
|
||||
enterStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
exitStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
transform={[{ translateY: 0 }]}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
viewportName={currentToast.viewportName}
|
||||
>
|
||||
<YStack>
|
||||
<Toast.Title>{currentToast.title}</Toast.Title>
|
||||
{!!currentToast.message && <Toast.Description>{currentToast.message}</Toast.Description>}
|
||||
</YStack>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
30
packages/ui/src/Settings/src/items/SettingsGroup.tsx
Normal file
30
packages/ui/src/Settings/src/items/SettingsGroup.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
import { Settings, styled, View, ListItem } from '@my/ui'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const GroupContainer = styled(View, {
|
||||
marginTop: '$2',
|
||||
marginBottom: '$2',
|
||||
})
|
||||
|
||||
const GroupTitle = styled(ListItem.Subtitle, {
|
||||
marginHorizontal: '$4',
|
||||
})
|
||||
|
||||
export const SettingsGroup = (props: SettingsItemProps<'group'>) => {
|
||||
const { label, options, disabled } = props
|
||||
const { children } = options
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupContainer>
|
||||
<GroupTitle>{label}</GroupTitle>
|
||||
<Settings items={children?.map((item) => ({ ...item, disabled: disabled }))} />
|
||||
</GroupContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
69
packages/ui/src/Settings/src/items/SettingsInput.tsx
Normal file
69
packages/ui/src/Settings/src/items/SettingsInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Button, Input, XStack, YStack, styled, Text, View } from '@my/ui'
|
||||
import { withSettingsSheet, WithSettingsSheetProps } from './SettingsSheet'
|
||||
import { useState } from 'react'
|
||||
import { settingsStore } from '../../store'
|
||||
|
||||
const Title = styled(Text, {
|
||||
color: '$color',
|
||||
fontSize: '$6',
|
||||
})
|
||||
|
||||
const Description = styled(Text, {
|
||||
color: '$placeholderColor',
|
||||
fontSize: '$4',
|
||||
})
|
||||
|
||||
const Container = styled(YStack, {
|
||||
width: '100%',
|
||||
// TODO 这是一个 bug 会导致输入框顶部莫名奇妙的空间
|
||||
height: 380,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '$4',
|
||||
// backgroundColor: '#333',
|
||||
paddingTop: 0,
|
||||
paddingBottom: '$6',
|
||||
})
|
||||
|
||||
const Footer = styled(XStack, {
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: '$2',
|
||||
})
|
||||
|
||||
const SettingsInputComponent = (props: WithSettingsSheetProps<'input'>) => {
|
||||
const { label, icon, description, disabled, options, onClose } = props
|
||||
const [value, setValue] = useState('')
|
||||
/**
|
||||
* 确认按钮事件
|
||||
* @param value
|
||||
*/
|
||||
const handleConfirm = (value: string) => {
|
||||
// TODO: 这里可以处理确认逻辑,如回调、校验等
|
||||
settingsStore.setSetting(props.id, value)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>{label}</Title>
|
||||
<Input
|
||||
width="100%"
|
||||
size={'$4'}
|
||||
placeholder={`请输入${label}...`}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
/>
|
||||
{description && <Description>{description}</Description>}
|
||||
<Footer>
|
||||
<Button onPress={onClose}>取消</Button>
|
||||
<Button themeInverse onPress={() => handleConfirm(value)}>
|
||||
确定
|
||||
</Button>
|
||||
</Footer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsInput = withSettingsSheet(SettingsInputComponent)
|
||||
21
packages/ui/src/Settings/src/items/SettingsLine.tsx
Normal file
21
packages/ui/src/Settings/src/items/SettingsLine.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Separator, styled } from '@my/ui'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const Line = styled(Separator, {
|
||||
marginVertical: '$4',
|
||||
})
|
||||
|
||||
export const SettingsLine = (props: SettingsItemProps<'alert'>) => {
|
||||
const { label } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line alignSelf="stretch" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
39
packages/ui/src/Settings/src/items/SettingsLink.tsx
Normal file
39
packages/ui/src/Settings/src/items/SettingsLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ListItem } from '@my/ui'
|
||||
import { ChevronRight } from '@tamagui/lucide-icons'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
import { useCallback } from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SettingsLink = (props: SettingsItemProps<'link'>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
|
||||
const gotoLink = useCallback(async () => {
|
||||
const { url } = options
|
||||
const supported = await Linking.canOpenURL(url)
|
||||
if (supported) {
|
||||
await Linking.openURL(url)
|
||||
} else {
|
||||
console.log('无法打开该 URL: ' + url)
|
||||
}
|
||||
}, [options])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={label}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
onPress={gotoLink}
|
||||
iconAfter={ChevronRight}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
packages/ui/src/Settings/src/items/SettingsPage.tsx
Normal file
61
packages/ui/src/Settings/src/items/SettingsPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ListItem, Toast, useToastController, useToastState, YStack } from '@my/ui'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SettingsPage = (props: SettingsItemProps<'alert'>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
const toast = useToastController()
|
||||
|
||||
const doAlert = useCallback(() => {
|
||||
const { type, message = '', description = '' } = options
|
||||
toast.show(message, {
|
||||
message: description,
|
||||
type,
|
||||
demo: true,
|
||||
})
|
||||
}, [options])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CurrentToast />
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={label}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
onPress={disabled ? undefined : () => doAlert()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CurrentToast = () => {
|
||||
const currentToast = useToastState()
|
||||
|
||||
if (!currentToast || currentToast.isHandledNatively) return null
|
||||
|
||||
return (
|
||||
<Toast
|
||||
animation="100ms"
|
||||
key={currentToast.id}
|
||||
duration={currentToast.duration}
|
||||
enterStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
exitStyle={{ opacity: 0, transform: [{ translateY: 100 }] }}
|
||||
transform={[{ translateY: 0 }]}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
viewportName={currentToast.viewportName}
|
||||
>
|
||||
<YStack>
|
||||
<Toast.Title>{currentToast.title}</Toast.Title>
|
||||
{!!currentToast.message && <Toast.Description>{currentToast.message}</Toast.Description>}
|
||||
</YStack>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
12
packages/ui/src/Settings/src/items/SettingsSelect.tsx
Normal file
12
packages/ui/src/Settings/src/items/SettingsSelect.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ListItem, Sheet } from '@my/ui'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
import { useState } from 'react'
|
||||
import { withSettingsSheet, WithSettingsSheetProps } from './SettingsSheet'
|
||||
|
||||
const SettingsSelectComponent = (props: WithSettingsSheetProps<'select'>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export const SettingsSelect = withSettingsSheet(SettingsSelectComponent)
|
||||
62
packages/ui/src/Settings/src/items/SettingsSheet.tsx
Normal file
62
packages/ui/src/Settings/src/items/SettingsSheet.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ListItem, Sheet } from '@my/ui'
|
||||
import type { SettingsItemProps, SettingsItemType } from '@my/ui'
|
||||
import { useState, ComponentType } from 'react'
|
||||
|
||||
export type WithSettingsSheetProps<T extends SettingsItemType> = SettingsItemProps<T> & {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const withSettingsSheet = <T extends SettingsItemType>(
|
||||
SheetComponent: ComponentType<WithSettingsSheetProps<T>>
|
||||
) => {
|
||||
return (props: SettingsItemProps<T>) => {
|
||||
const { label, icon, description, disabled, options } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const openSheet = () => setOpen(true)
|
||||
const closeSheet = () => setOpen(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={label}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
onPress={openSheet}
|
||||
/>
|
||||
|
||||
<Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
modal={true}
|
||||
snapPointsMode="fit"
|
||||
dismissOnSnapToBottom
|
||||
zIndex={100_000}
|
||||
animation="100ms"
|
||||
>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="$shadow6"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
|
||||
<Sheet.Handle />
|
||||
<Sheet.Frame
|
||||
padding="$4"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
gap="$5"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<SheetComponent {...props} onClose={closeSheet} />
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
50
packages/ui/src/Settings/src/items/SettingsSwitch.tsx
Normal file
50
packages/ui/src/Settings/src/items/SettingsSwitch.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ListItem, Switch } from '@my/ui'
|
||||
import type { SettingsItemProps } from '@my/ui'
|
||||
import { useState } from 'react'
|
||||
import { settingsStore } from '../../store'
|
||||
|
||||
export interface SettingsAlertProps {
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SettingsSwitch = (props: SettingsItemProps<'switch'>) => {
|
||||
const { id, label, icon, description, disabled, options } = props
|
||||
const { defaultValue } = options
|
||||
const [value, setValue] = useState(!!defaultValue)
|
||||
|
||||
const handleToggle = disabled
|
||||
? undefined
|
||||
: () => {
|
||||
setValue((prev) => {
|
||||
const next = !prev
|
||||
settingsStore.setSetting(props.id, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={<>{label}</>}
|
||||
subTitle={description}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
onPress={handleToggle}
|
||||
iconAfter={
|
||||
<Switch
|
||||
id={id}
|
||||
size={'$2'}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={disabled}
|
||||
checked={value}
|
||||
>
|
||||
<Switch.Thumb animation="100ms" />
|
||||
</Switch>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
186
packages/ui/src/Settings/store/index.ts
Normal file
186
packages/ui/src/Settings/store/index.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { openDatabase } from 'react-native-sqlite-storage'
|
||||
import type { SQLiteDatabase } from 'react-native-sqlite-storage'
|
||||
import { makeAutoObservable, runInAction, observable } from 'mobx'
|
||||
import { debounce } from 'lodash'
|
||||
import { safe } from '@esdora/kit'
|
||||
/**
|
||||
* 2分钟对应的毫秒数量
|
||||
*/
|
||||
const DB_DELAY_MS = 2 * 60 * 1000
|
||||
const DB_SETTINGS_NAME = 'settings.db'
|
||||
/**
|
||||
* 使用内存 Map 管理应用设置,并持久化到 SQLite 数据库。
|
||||
*
|
||||
* - 初始化时从数据库加载设置。
|
||||
* - 提供获取和设置单个设置的方法。
|
||||
* - 自动将更改保存到数据库。
|
||||
*
|
||||
* @说明
|
||||
* 使用 MobX 的 `makeAutoObservable` 进行状态管理,并假定有用于 SQLite 操作的 `db` 对象。
|
||||
*/
|
||||
class SettingStore {
|
||||
private settings: Map<string, string>
|
||||
private isDbClosed: boolean
|
||||
private db: SQLiteDatabase
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
this.settings = observable.map<string, string>()
|
||||
this.isDbClosed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库并加载设置
|
||||
*/
|
||||
public initDB = safe(async () => {
|
||||
this.db = await openDatabase({ name: DB_SETTINGS_NAME })
|
||||
await this._loadFromDB()
|
||||
})
|
||||
|
||||
/**
|
||||
* 关闭数据库
|
||||
*/
|
||||
private _closeDB = debounce(
|
||||
safe(async () => {
|
||||
const { db } = this
|
||||
if (db) {
|
||||
await db.close()
|
||||
this.isDbClosed = true
|
||||
}
|
||||
}),
|
||||
DB_DELAY_MS
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动判断重连
|
||||
*/
|
||||
private _ensureDB = safe(async () => {
|
||||
const { db, isDbClosed } = this
|
||||
|
||||
if (!db || isDbClosed) {
|
||||
this.db = await openDatabase({ name: DB_SETTINGS_NAME })
|
||||
this.isDbClosed = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 将设置从 sqlite 中读取
|
||||
*/
|
||||
private _loadFromDB = safe(async () => {
|
||||
const { db, _ensureDB, _closeDB } = this
|
||||
await _ensureDB()
|
||||
db.transaction((tx) => {
|
||||
tx.executeSql('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);')
|
||||
tx.executeSql('SELECT key, value FROM settings', [], (_, result) => {
|
||||
const map = new Map<string, string>()
|
||||
for (let i = 0; i < result.rows.length; i++) {
|
||||
const row = result.rows.item(i)
|
||||
map.set(row.key, row.value)
|
||||
}
|
||||
runInAction(() => {
|
||||
this.settings.clear()
|
||||
for (const [key, value] of map.entries()) {
|
||||
this.settings.set(key, value)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
_closeDB()
|
||||
})
|
||||
|
||||
/**
|
||||
* 将设置保存到 sqlite
|
||||
*/
|
||||
private _saveToDB = debounce(
|
||||
safe(async () => {
|
||||
const { db, _ensureDB, _closeDB } = this
|
||||
await _ensureDB()
|
||||
// 先读取数据库所有设置
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
db.transaction((tx) => {
|
||||
tx.executeSql(
|
||||
'SELECT key, value FROM settings',
|
||||
[],
|
||||
async (_, result) => {
|
||||
const dbMap = new Map<string, string>()
|
||||
for (let i = 0; i < result.rows.length; i++) {
|
||||
const row = result.rows.item(i)
|
||||
dbMap.set(row.key, row.value)
|
||||
}
|
||||
// 记录需要删除的 key(数据库有但内存没有)
|
||||
const toDelete: string[] = []
|
||||
dbMap.forEach((_, key) => {
|
||||
if (!this.settings.has(key)) {
|
||||
toDelete.push(key)
|
||||
}
|
||||
})
|
||||
// 记录需要插入/更新的 key(内存有但数据库没有或值不同)
|
||||
const toUpsert: [string, string][] = []
|
||||
this.settings.forEach((value, key) => {
|
||||
if (!dbMap.has(key) || dbMap.get(key) !== value) {
|
||||
toUpsert.push([key, value])
|
||||
}
|
||||
})
|
||||
// 执行删除和 upsert
|
||||
db.transaction((tx2) => {
|
||||
toDelete.forEach((key) => {
|
||||
tx2.executeSql('DELETE FROM settings WHERE key = ?;', [key])
|
||||
})
|
||||
toUpsert.forEach(([key, value]) => {
|
||||
tx2.executeSql('REPLACE INTO settings (key, value) VALUES (?, ?);', [key, value])
|
||||
})
|
||||
})
|
||||
resolve()
|
||||
},
|
||||
(err) => {
|
||||
reject(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
_closeDB()
|
||||
}),
|
||||
DB_DELAY_MS
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据 key 获取设置的值。
|
||||
*
|
||||
* @param key - 要获取的设置的 key。
|
||||
* @returns 返回指定 key 对应的值,如果不存在则为 `undefined`。
|
||||
*/
|
||||
getSetting = safe((key: string) => {
|
||||
const { settings } = this
|
||||
return settings.get(key)
|
||||
})
|
||||
|
||||
/**
|
||||
* 使用指定的 key 和 value 更新设置,并将更改持久化到数据库。
|
||||
*
|
||||
* @param key - 要更新的设置的 key。
|
||||
* @param value - 设置的新值。
|
||||
*/
|
||||
setSetting = safe(async (key: string, value: string) => {
|
||||
const { settings, _saveToDB } = this
|
||||
settings.set(key, value)
|
||||
await _saveToDB()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 MobX 和 SQLite 持久化管理应用设置的 Store。
|
||||
*
|
||||
* @说明
|
||||
* - 初始化时从 SQLite 数据库加载设置。
|
||||
* - 提供获取和设置的方法。
|
||||
* - 自动将更改持久化到数据库。
|
||||
*
|
||||
* @示例
|
||||
* ```typescript
|
||||
* settingsStore.setSetting('theme', 'dark');
|
||||
* const theme = settingsStore.getSetting('theme');
|
||||
* ```
|
||||
*/
|
||||
export const settingsStore = new SettingStore()
|
||||
|
||||
await settingsStore.initDB()
|
||||
@@ -5,3 +5,4 @@ export { config } from '@my/config'
|
||||
export * from './CustomToast'
|
||||
export * from './SwitchThemeButton'
|
||||
export * from './SwitchRouterButton'
|
||||
export * from './Settings'
|
||||
|
||||
4
packages/ui/src/types.d.ts
vendored
4
packages/ui/src/types.d.ts
vendored
@@ -3,5 +3,7 @@ import { config } from '@my/config'
|
||||
export type Conf = typeof config
|
||||
|
||||
declare module 'tamagui' {
|
||||
interface TamaguiCustomConfig extends Conf {}
|
||||
interface TamaguiCustomConfig extends Conf {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"app/*": ["./packages/app/*"],
|
||||
"@my/ui/*": ["./packages/ui/*"]
|
||||
"app/*": [
|
||||
"./packages/app/*"
|
||||
],
|
||||
"@my/ui/*": [
|
||||
"./packages/ui/*"
|
||||
],
|
||||
"@my/decorators/*": [
|
||||
"./packages/decorators/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"extends": "./tsconfig.base",
|
||||
@@ -17,4 +24,4 @@
|
||||
"apps/next/.next",
|
||||
"apps/next/.tamagui"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user