Compare commits

..

3 Commits

Author SHA1 Message Date
3ff5d7fdc3 feat: 添加对数据库初始化的调用,优化设置存储逻辑 2025-08-29 14:47:20 +08:00
244f702542 fix: 修复设置项渲染函数的返回值类型 2025-08-29 14:35:06 +08:00
4a308f05cf feat: 增加了基础的设置相关内容
包括 子组件input、switch、alert、line、link、group
包括 实现 SettingStore 状态管理 (MobX) 支持持久化
包括 类型相关内容声明
2025-08-29 14:22:47 +08:00
31 changed files with 22646 additions and 2977 deletions

View File

@@ -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"

View 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
View 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)

View File

@@ -1,6 +1,7 @@
._ovs-contain {overscroll-behavior:contain;}
.is_Text .is_Text {display:inline-flex;}
._dsp_contents {display:contents;}
._no_backdrop::backdrop {display: none;}
:root {--c-radius-0:0px;--c-radius-1:3px;--c-radius-2:5px;--c-radius-3:7px;--c-radius-4:9px;--c-radius-5:10px;--c-radius-6:16px;--c-radius-7:19px;--c-radius-8:22px;--c-radius-9:26px;--c-radius-10:34px;--c-radius-11:42px;--c-radius-12:50px;--c-radius-true:9px;--c-zIndex-0:0;--c-zIndex-1:100;--c-zIndex-2:200;--c-zIndex-3:300;--c-zIndex-4:400;--c-zIndex-5:500;--c-space-0:0px;--c-space-1:2px;--c-space-2:7px;--c-space-3:13px;--c-space-4:18px;--c-space-5:24px;--c-space-6:32px;--c-space-7:39px;--c-space-8:46px;--c-space-9:53px;--c-space-10:60px;--c-space-11:74px;--c-space-12:88px;--c-space-13:102px;--c-space-14:116px;--c-space-15:130px;--c-space-16:144px;--c-space-17:144px;--c-space-18:158px;--c-space-19:172px;--c-space-20:186px;--c-space-0--25:0.5px;--c-space-0--5:1px;--c-space-0--75:1.5px;--c-space-1--5:4px;--c-space-2--5:10px;--c-space-3--5:16px;--c-space-true:18px;--c-space-4--5:21px;--c-space--0--25:-0.5px;--c-space--0--5:-1px;--c-space--0--75:-1.5px;--c-space--1:-2px;--c-space--1--5:-4px;--c-space--2:-7px;--c-space--2--5:-10px;--c-space--3:-13px;--c-space--3--5:-16px;--c-space--4:-18px;--c-space--true:-18px;--c-space--4--5:-21px;--c-space--5:-24px;--c-space--6:-32px;--c-space--7:-39px;--c-space--8:-46px;--c-space--9:-53px;--c-space--10:-60px;--c-space--11:-74px;--c-space--12:-88px;--c-space--13:-102px;--c-space--14:-116px;--c-space--15:-130px;--c-space--16:-144px;--c-space--17:-144px;--c-space--18:-158px;--c-space--19:-172px;--c-space--20:-186px;--c-size-0:0px;--c-size-1:20px;--c-size-2:28px;--c-size-3:36px;--c-size-4:44px;--c-size-5:52px;--c-size-6:64px;--c-size-7:74px;--c-size-8:84px;--c-size-9:94px;--c-size-10:104px;--c-size-11:124px;--c-size-12:144px;--c-size-13:164px;--c-size-14:184px;--c-size-15:204px;--c-size-16:224px;--c-size-17:224px;--c-size-18:244px;--c-size-19:264px;--c-size-20:284px;--c-size-0--25:2px;--c-size-0--5:4px;--c-size-0--75:8px;--c-size-1--5:24px;--c-size-2--5:32px;--c-size-3--5:40px;--c-size-true:44px;--c-size-4--5:48px}
:root .font_body, :root .t_lang-body-default .font_body {--f-family:Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--f-lineHeight-1:23px;--f-lineHeight-2:24px;--f-lineHeight-3:25px;--f-lineHeight-4:27px;--f-lineHeight-5:30px;--f-lineHeight-6:32px;--f-lineHeight-7:34px;--f-lineHeight-8:38px;--f-lineHeight-9:46px;--f-lineHeight-10:66px;--f-lineHeight-11:77px;--f-lineHeight-12:85px;--f-lineHeight-13:97px;--f-lineHeight-14:121px;--f-lineHeight-15:148px;--f-lineHeight-16:172px;--f-lineHeight-true:27px;--f-weight-1:300;--f-weight-2:300;--f-weight-3:300;--f-weight-4:300;--f-weight-5:300;--f-weight-6:300;--f-weight-7:300;--f-weight-8:300;--f-weight-9:300;--f-weight-10:300;--f-weight-11:300;--f-weight-12:300;--f-weight-13:300;--f-weight-14:300;--f-weight-15:300;--f-weight-16:300;--f-weight-true:300;--f-letterSpacing-1:0px;--f-letterSpacing-2:0px;--f-letterSpacing-3:0px;--f-letterSpacing-4:0px;--f-letterSpacing-5:0px;--f-letterSpacing-6:0px;--f-letterSpacing-7:0px;--f-letterSpacing-8:0px;--f-letterSpacing-9:0px;--f-letterSpacing-10:0px;--f-letterSpacing-11:0px;--f-letterSpacing-12:0px;--f-letterSpacing-13:0px;--f-letterSpacing-14:0px;--f-letterSpacing-15:0px;--f-letterSpacing-16:0px;--f-letterSpacing-true:0px;--f-size-1:12px;--f-size-2:13px;--f-size-3:14px;--f-size-4:15px;--f-size-5:18px;--f-size-6:20px;--f-size-7:22px;--f-size-8:25px;--f-size-9:33px;--f-size-10:51px;--f-size-11:61px;--f-size-12:68px;--f-size-13:79px;--f-size-14:101px;--f-size-15:125px;--f-size-16:147px;--f-size-true:15px}
:root .font_heading, :root .t_lang-heading-default .font_heading {--f-family:Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--f-lineHeight-1:21px;--f-lineHeight-2:22px;--f-lineHeight-3:23px;--f-lineHeight-4:24px;--f-lineHeight-5:26px;--f-lineHeight-6:25px;--f-lineHeight-7:30px;--f-lineHeight-8:33px;--f-lineHeight-9:40px;--f-lineHeight-10:56px;--f-lineHeight-11:65px;--f-lineHeight-12:72px;--f-lineHeight-13:82px;--f-lineHeight-14:102px;--f-lineHeight-15:124px;--f-lineHeight-16:144px;--f-lineHeight-true:24px;--f-weight-1:400;--f-weight-2:400;--f-weight-3:400;--f-weight-4:400;--f-weight-5:400;--f-weight-6:400;--f-weight-7:700;--f-weight-8:700;--f-weight-9:700;--f-weight-10:700;--f-weight-11:700;--f-weight-12:700;--f-weight-13:700;--f-weight-14:700;--f-weight-15:700;--f-weight-16:700;--f-weight-true:700;--f-letterSpacing-1:2px;--f-letterSpacing-2:2px;--f-letterSpacing-3:2px;--f-letterSpacing-4:2px;--f-letterSpacing-5:2px;--f-letterSpacing-6:1px;--f-letterSpacing-7:0px;--f-letterSpacing-8:-1px;--f-letterSpacing-9:-2px;--f-letterSpacing-10:-3px;--f-letterSpacing-11:-3px;--f-letterSpacing-12:-4px;--f-letterSpacing-13:-4px;--f-letterSpacing-14:-5px;--f-letterSpacing-15:-6px;--f-letterSpacing-16:-6px;--f-letterSpacing-true:-6px;--f-size-1:11px;--f-size-2:12px;--f-size-3:13px;--f-size-4:14px;--f-size-5:16px;--f-size-6:15px;--f-size-7:20px;--f-size-8:23px;--f-size-9:30px;--f-size-10:46px;--f-size-11:55px;--f-size-12:62px;--f-size-13:72px;--f-size-14:92px;--f-size-15:114px;--f-size-16:134px;--f-size-true:14px;--f-transform-1:uppercase;--f-transform-2:uppercase;--f-transform-3:uppercase;--f-transform-4:uppercase;--f-transform-5:uppercase;--f-transform-6:uppercase;--f-transform-7:none;--f-transform-8:none;--f-transform-9:none;--f-transform-10:none;--f-transform-11:none;--f-transform-12:none;--f-transform-13:none;--f-transform-14:none;--f-transform-15:none;--f-transform-16:none;--f-transform-true:none}

17696
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>

View 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>
)
}

View File

@@ -2,7 +2,6 @@ import { defaultConfig } from '@tamagui/config/v4'
import { createTamagui } from 'tamagui'
import { bodyFont, headingFont } from './fonts'
import { animations } from './animations'
export const config = createTamagui({
...defaultConfig,
animations,
@@ -10,8 +9,8 @@ export const config = createTamagui({
body: bodyFont,
heading: headingFont,
},
settings:{
settings: {
...defaultConfig.settings,
onlyAllowShorthands: false
}
onlyAllowShorthands: false,
},
})

View 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"
}
}

View 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
View File

@@ -0,0 +1,7 @@
import { config } from '@my/config'
export type Conf = typeof config
declare module 'tamagui' {
interface TamaguiCustomConfig extends Conf {}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base",
"include": [
"**/*.ts",
"**/*.tsx",
"../config/src/tamagui.config.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx"
},
"references": []
}

View File

@@ -0,0 +1,4 @@
export * from './src/Settings'
export * from './src/SettingsItem'
export * from './types/Settings.types'
export * from './types/SettingsItem.types'

View 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>
)
}

View 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 () => <></>
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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)

View 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" />
</>
)
}

View 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}
/>
</>
)
}

View 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>
)
}

View 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)

View 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>
</>
)
}
}

View 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>
}
/>
</>
)
}

View File

@@ -0,0 +1,184 @@
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, initDB } = this
if (!db || isDbClosed) {
await initDB()
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()

View File

@@ -5,3 +5,4 @@ export { config } from '@my/config'
export * from './CustomToast'
export * from './SwitchThemeButton'
export * from './SwitchRouterButton'
export * from './Settings'

View File

@@ -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 {
}
}

View File

@@ -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"
]
}
}

6843
yarn.lock

File diff suppressed because it is too large Load Diff