create-pika-minigame 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/bin/cli.js +189 -0
- package/package.json +28 -0
- package/templates/README.md +196 -0
- package/templates/app.json.template +29 -0
- package/templates/babel.config.js +7 -0
- package/templates/dev/DevApp.tsx +47 -0
- package/templates/dev/MockHost.ts +131 -0
- package/templates/dev/index.js +9 -0
- package/templates/index.js +8 -0
- package/templates/package.json.template +41 -0
- package/templates/rspack.config.mjs +81 -0
- package/templates/src/App.tsx +215 -0
- package/templates/src/components/Button.tsx +117 -0
- package/templates/src/components/Card.tsx +86 -0
- package/templates/src/components/Spinner.tsx +68 -0
- package/templates/src/components/TopBar.tsx +109 -0
- package/templates/src/components/index.ts +11 -0
- package/templates/src/tokens.ts +109 -0
- package/templates/src/types.ts +53 -0
- package/templates/tsconfig.json +13 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME_KEBAB}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Pika mini-game: {{PROJECT_NAME_PASCAL}}",
|
|
6
|
+
"main": "dev/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "expo start",
|
|
9
|
+
"dev:ios": "expo run:ios",
|
|
10
|
+
"dev:android": "expo run:android",
|
|
11
|
+
"build:ios": "npx react-native webpack-bundle --platform ios --entry-file index.js --dev false --bundle-output build/outputs/ios/local/index.ios.bundle --assets-dest build/outputs/ios/local",
|
|
12
|
+
"build:android": "npx react-native webpack-bundle --platform android --entry-file index.js --dev false --bundle-output build/outputs/android/local/index.android.bundle --assets-dest build/outputs/android/local",
|
|
13
|
+
"serve:ios": "npx serve build/outputs/ios/remotes -l 9000 --cors",
|
|
14
|
+
"serve:android": "npx serve build/outputs/android/remotes -l 9000 --cors",
|
|
15
|
+
"deploy": "echo 'Configure your deployment in package.json scripts'",
|
|
16
|
+
"lint": "eslint src/ dev/",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"expo": "~52.0.0",
|
|
21
|
+
"expo-status-bar": "~2.0.0",
|
|
22
|
+
"react": "18.3.1",
|
|
23
|
+
"react-native": "0.77.0",
|
|
24
|
+
"react-native-gesture-handler": "~2.20.0",
|
|
25
|
+
"react-native-reanimated": "~3.16.0",
|
|
26
|
+
"react-native-safe-area-context": "4.12.0",
|
|
27
|
+
"react-native-screens": "~4.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@babel/core": "^7.25.0",
|
|
31
|
+
"@callstack/repack": "^5.0.0",
|
|
32
|
+
"@callstack/repack-plugin-reanimated": "^1.0.0",
|
|
33
|
+
"@types/react": "~18.3.0",
|
|
34
|
+
"typescript": "~5.3.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": "18.3.1",
|
|
38
|
+
"react-native": "0.77.0",
|
|
39
|
+
"react-native-reanimated": "~3.16.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import * as Repack from '@callstack/repack';
|
|
4
|
+
import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rspack config for Module Federation remote.
|
|
11
|
+
*
|
|
12
|
+
* This bundles your mini-game as a remote that can be loaded
|
|
13
|
+
* by the Pika host app at runtime.
|
|
14
|
+
*/
|
|
15
|
+
export default Repack.defineRspackConfig((env) => {
|
|
16
|
+
const { mode, platform } = env;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
mode,
|
|
20
|
+
context: __dirname,
|
|
21
|
+
entry: './index.js',
|
|
22
|
+
output: {
|
|
23
|
+
path: `${__dirname}/build/outputs/${platform}/remotes`,
|
|
24
|
+
uniqueName: '{{PROJECT_NAME_SNAKE}}',
|
|
25
|
+
// Set this to your CDN URL for production builds:
|
|
26
|
+
// MF_PUBLIC_PATH=https://your-cdn.com/games/{{PROJECT_NAME_KEBAB}}/
|
|
27
|
+
publicPath: process.env.MF_PUBLIC_PATH || 'http://localhost:9000/',
|
|
28
|
+
},
|
|
29
|
+
resolve: {
|
|
30
|
+
...Repack.getResolveOptions({ enablePackageExports: true }),
|
|
31
|
+
},
|
|
32
|
+
module: {
|
|
33
|
+
rules: [
|
|
34
|
+
{
|
|
35
|
+
test: /\.[cm]?[jt]sx?$/,
|
|
36
|
+
type: 'javascript/auto',
|
|
37
|
+
use: {
|
|
38
|
+
loader: '@callstack/repack/babel-swc-loader',
|
|
39
|
+
parallel: true,
|
|
40
|
+
options: {},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
...Repack.getAssetTransformRules({ svg: 'svgr' }),
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
plugins: [
|
|
47
|
+
new Repack.RepackPlugin({
|
|
48
|
+
extraChunks: [
|
|
49
|
+
{
|
|
50
|
+
include: /.*/,
|
|
51
|
+
type: 'remote',
|
|
52
|
+
outputPath: `build/outputs/${platform}/remotes`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
new ReanimatedPlugin({ unstable_disableTransform: true }),
|
|
57
|
+
new Repack.plugins.ModuleFederationPluginV2({
|
|
58
|
+
name: '{{PROJECT_NAME_SNAKE}}',
|
|
59
|
+
filename: '{{PROJECT_NAME_SNAKE}}.container.js.bundle',
|
|
60
|
+
dts: false,
|
|
61
|
+
exposes: {
|
|
62
|
+
'./App': './src/App.tsx',
|
|
63
|
+
},
|
|
64
|
+
shared: {
|
|
65
|
+
react: { singleton: true, eager: false, requiredVersion: '18.3.1' },
|
|
66
|
+
'react-native': { singleton: true, eager: false, requiredVersion: '0.77.0' },
|
|
67
|
+
'react-native-reanimated': { singleton: true, eager: false },
|
|
68
|
+
'react-native-gesture-handler': { singleton: true, eager: false },
|
|
69
|
+
'react-native-safe-area-context': { singleton: true, eager: false },
|
|
70
|
+
'react-native-screens': { singleton: true, eager: false },
|
|
71
|
+
'react-native-svg': { singleton: true, eager: false },
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
ignoreWarnings: [
|
|
76
|
+
(warning) =>
|
|
77
|
+
/Critical dependency: require function is used/.test(warning.message) &&
|
|
78
|
+
/react-native-reanimated/.test(warning.module?.resource ?? ''),
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
+
import { TopBar, Button, Card } from './components';
|
|
4
|
+
import { semantic, space, typography, color, radius } from './tokens';
|
|
5
|
+
import { GameProps, Screen } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* {{PROJECT_NAME_PASCAL}} - Main App Component
|
|
9
|
+
*
|
|
10
|
+
* This is your mini-game entry point. The host app loads this component
|
|
11
|
+
* and passes the `host` bridge for native capabilities.
|
|
12
|
+
*
|
|
13
|
+
* In dev mode (yarn dev), the host is mocked so you can test standalone.
|
|
14
|
+
*/
|
|
15
|
+
const App: React.FC<GameProps> = ({ onExit, onGameOver, host }) => {
|
|
16
|
+
const [screen, setScreen] = useState<Screen>('home');
|
|
17
|
+
const [score, setScore] = useState(0);
|
|
18
|
+
|
|
19
|
+
const handleStartGame = useCallback(() => {
|
|
20
|
+
setScreen('game');
|
|
21
|
+
setScore(0);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const handleGameComplete = useCallback((finalScore: number) => {
|
|
25
|
+
setScore(finalScore);
|
|
26
|
+
setScreen('result');
|
|
27
|
+
onGameOver?.({ score: finalScore });
|
|
28
|
+
}, [onGameOver]);
|
|
29
|
+
|
|
30
|
+
const handlePlayAgain = useCallback(() => {
|
|
31
|
+
setScreen('home');
|
|
32
|
+
setScore(0);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
// Home Screen
|
|
36
|
+
if (screen === 'home') {
|
|
37
|
+
return (
|
|
38
|
+
<View style={styles.container}>
|
|
39
|
+
<TopBar
|
|
40
|
+
left="close"
|
|
41
|
+
onLeftPress={onExit}
|
|
42
|
+
right={<Text style={styles.badge}>{{PROJECT_NAME_PASCAL}}</Text>}
|
|
43
|
+
/>
|
|
44
|
+
<View style={styles.body}>
|
|
45
|
+
<Text style={styles.title}>Welcome!</Text>
|
|
46
|
+
<Text style={styles.subtitle}>
|
|
47
|
+
This is your mini-game template. Edit App.tsx to build your game.
|
|
48
|
+
</Text>
|
|
49
|
+
|
|
50
|
+
<Card style={styles.infoCard}>
|
|
51
|
+
<View style={styles.cardContent}>
|
|
52
|
+
<Text style={styles.cardTitle}>Getting Started</Text>
|
|
53
|
+
<Text style={styles.cardText}>
|
|
54
|
+
1. Edit this file to create your game{'\n'}
|
|
55
|
+
2. Use tokens.ts for consistent styling{'\n'}
|
|
56
|
+
3. Use components/ for UI elements{'\n'}
|
|
57
|
+
4. Host bridge in types.ts for native features
|
|
58
|
+
</Text>
|
|
59
|
+
</View>
|
|
60
|
+
</Card>
|
|
61
|
+
|
|
62
|
+
<Button
|
|
63
|
+
label="Start Game"
|
|
64
|
+
variant="primary"
|
|
65
|
+
size="lg"
|
|
66
|
+
fullWidth
|
|
67
|
+
onPress={handleStartGame}
|
|
68
|
+
style={styles.startButton}
|
|
69
|
+
/>
|
|
70
|
+
</View>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Game Screen (placeholder - build your game logic here)
|
|
76
|
+
if (screen === 'game') {
|
|
77
|
+
return (
|
|
78
|
+
<View style={styles.container}>
|
|
79
|
+
<TopBar
|
|
80
|
+
left="back"
|
|
81
|
+
onLeftPress={() => setScreen('home')}
|
|
82
|
+
title="Playing..."
|
|
83
|
+
/>
|
|
84
|
+
<View style={styles.body}>
|
|
85
|
+
<Text style={styles.title}>Game Screen</Text>
|
|
86
|
+
<Text style={styles.subtitle}>
|
|
87
|
+
Build your game mechanics here!
|
|
88
|
+
</Text>
|
|
89
|
+
|
|
90
|
+
<View style={styles.gameArea}>
|
|
91
|
+
<Text style={styles.emoji}>🎮</Text>
|
|
92
|
+
<Text style={styles.gameText}>
|
|
93
|
+
Use host.pronunciation for voice features,{'\n'}
|
|
94
|
+
or add your own game logic.
|
|
95
|
+
</Text>
|
|
96
|
+
</View>
|
|
97
|
+
|
|
98
|
+
<Button
|
|
99
|
+
label="Complete Game (Score: 85)"
|
|
100
|
+
variant="primary"
|
|
101
|
+
size="lg"
|
|
102
|
+
fullWidth
|
|
103
|
+
onPress={() => handleGameComplete(85)}
|
|
104
|
+
style={styles.startButton}
|
|
105
|
+
/>
|
|
106
|
+
</View>
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Result Screen
|
|
112
|
+
return (
|
|
113
|
+
<View style={styles.container}>
|
|
114
|
+
<TopBar title="Results" />
|
|
115
|
+
<View style={styles.body}>
|
|
116
|
+
<Text style={styles.emoji}>🎉</Text>
|
|
117
|
+
<Text style={styles.title}>Great Job!</Text>
|
|
118
|
+
<Text style={styles.scoreText}>{score}</Text>
|
|
119
|
+
<Text style={styles.subtitle}>points</Text>
|
|
120
|
+
|
|
121
|
+
<Button
|
|
122
|
+
label="Play Again"
|
|
123
|
+
variant="primary"
|
|
124
|
+
size="lg"
|
|
125
|
+
fullWidth
|
|
126
|
+
onPress={handlePlayAgain}
|
|
127
|
+
style={styles.startButton}
|
|
128
|
+
/>
|
|
129
|
+
<Button
|
|
130
|
+
label="Exit"
|
|
131
|
+
variant="secondary"
|
|
132
|
+
size="md"
|
|
133
|
+
fullWidth
|
|
134
|
+
onPress={onExit}
|
|
135
|
+
style={styles.exitButton}
|
|
136
|
+
/>
|
|
137
|
+
</View>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const styles = StyleSheet.create({
|
|
143
|
+
container: {
|
|
144
|
+
flex: 1,
|
|
145
|
+
backgroundColor: semantic.bg,
|
|
146
|
+
},
|
|
147
|
+
badge: {
|
|
148
|
+
color: semantic.textSecondary,
|
|
149
|
+
fontSize: typography.tag.size,
|
|
150
|
+
},
|
|
151
|
+
body: {
|
|
152
|
+
flex: 1,
|
|
153
|
+
paddingHorizontal: space[5],
|
|
154
|
+
paddingTop: space[8],
|
|
155
|
+
alignItems: 'center',
|
|
156
|
+
},
|
|
157
|
+
title: {
|
|
158
|
+
color: semantic.text,
|
|
159
|
+
fontSize: typography.title1.size,
|
|
160
|
+
fontWeight: typography.title1.fontWeight,
|
|
161
|
+
marginBottom: space[2],
|
|
162
|
+
textAlign: 'center',
|
|
163
|
+
},
|
|
164
|
+
subtitle: {
|
|
165
|
+
color: semantic.textSecondary,
|
|
166
|
+
fontSize: typography.body.size,
|
|
167
|
+
textAlign: 'center',
|
|
168
|
+
marginBottom: space[6],
|
|
169
|
+
},
|
|
170
|
+
infoCard: {
|
|
171
|
+
width: '100%',
|
|
172
|
+
marginBottom: space[8],
|
|
173
|
+
},
|
|
174
|
+
cardContent: {
|
|
175
|
+
padding: space[4],
|
|
176
|
+
},
|
|
177
|
+
cardTitle: {
|
|
178
|
+
color: semantic.text,
|
|
179
|
+
fontSize: typography.title2.size,
|
|
180
|
+
fontWeight: typography.title2.fontWeight,
|
|
181
|
+
marginBottom: space[3],
|
|
182
|
+
},
|
|
183
|
+
cardText: {
|
|
184
|
+
color: semantic.textSecondary,
|
|
185
|
+
fontSize: typography.caption.size,
|
|
186
|
+
lineHeight: 22,
|
|
187
|
+
},
|
|
188
|
+
startButton: {
|
|
189
|
+
marginTop: space[4],
|
|
190
|
+
},
|
|
191
|
+
exitButton: {
|
|
192
|
+
marginTop: space[3],
|
|
193
|
+
},
|
|
194
|
+
gameArea: {
|
|
195
|
+
flex: 1,
|
|
196
|
+
justifyContent: 'center',
|
|
197
|
+
alignItems: 'center',
|
|
198
|
+
},
|
|
199
|
+
emoji: {
|
|
200
|
+
fontSize: 64,
|
|
201
|
+
marginBottom: space[4],
|
|
202
|
+
},
|
|
203
|
+
gameText: {
|
|
204
|
+
color: semantic.textSecondary,
|
|
205
|
+
fontSize: typography.body.size,
|
|
206
|
+
textAlign: 'center',
|
|
207
|
+
},
|
|
208
|
+
scoreText: {
|
|
209
|
+
color: color.cyan[400],
|
|
210
|
+
fontSize: typography.display.size,
|
|
211
|
+
fontWeight: typography.display.fontWeight,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
export default App;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
ActivityIndicator,
|
|
9
|
+
StyleProp,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { color, semantic, space, radius } from '../tokens';
|
|
13
|
+
|
|
14
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
15
|
+
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
16
|
+
|
|
17
|
+
export interface ButtonProps {
|
|
18
|
+
label: string;
|
|
19
|
+
variant?: ButtonVariant;
|
|
20
|
+
size?: ButtonSize;
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
fullWidth?: boolean;
|
|
24
|
+
leftIcon?: React.ReactNode;
|
|
25
|
+
rightIcon?: React.ReactNode;
|
|
26
|
+
onPress?: () => void;
|
|
27
|
+
style?: StyleProp<ViewStyle>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VARIANT_STYLE = {
|
|
31
|
+
primary: { bg: color.cyan[400], text: color.white },
|
|
32
|
+
secondary: { bg: semantic.surfaceElevated, text: color.white },
|
|
33
|
+
danger: { bg: color.red[400], text: color.white },
|
|
34
|
+
ghost: { bg: 'transparent', text: color.cyan[400], border: color.cyan[400] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const SIZE_STYLE = {
|
|
38
|
+
sm: { height: 40, paddingH: space[4], fontSize: 13 },
|
|
39
|
+
md: { height: 50, paddingH: space[6], fontSize: 15 },
|
|
40
|
+
lg: { height: 60, paddingH: space[8], fontSize: 17 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function Button({
|
|
44
|
+
label,
|
|
45
|
+
variant = 'primary',
|
|
46
|
+
size = 'md',
|
|
47
|
+
loading = false,
|
|
48
|
+
disabled = false,
|
|
49
|
+
fullWidth = false,
|
|
50
|
+
leftIcon,
|
|
51
|
+
rightIcon,
|
|
52
|
+
onPress,
|
|
53
|
+
style,
|
|
54
|
+
}: ButtonProps) {
|
|
55
|
+
const v = VARIANT_STYLE[variant];
|
|
56
|
+
const s = SIZE_STYLE[size];
|
|
57
|
+
const inactive = disabled || loading;
|
|
58
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
59
|
+
|
|
60
|
+
const pressIn = () => {
|
|
61
|
+
if (inactive) return;
|
|
62
|
+
Animated.timing(scale, { toValue: 0.96, duration: 80, useNativeDriver: true }).start();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const pressOut = () => {
|
|
66
|
+
if (inactive) return;
|
|
67
|
+
Animated.spring(scale, { toValue: 1, friction: 8, tension: 200, useNativeDriver: true }).start();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Pressable
|
|
72
|
+
onPress={inactive ? undefined : onPress}
|
|
73
|
+
onPressIn={pressIn}
|
|
74
|
+
onPressOut={pressOut}
|
|
75
|
+
style={[styles.pressable, fullWidth && styles.fullWidth, style]}
|
|
76
|
+
>
|
|
77
|
+
<Animated.View
|
|
78
|
+
style={[
|
|
79
|
+
styles.face,
|
|
80
|
+
{
|
|
81
|
+
height: s.height,
|
|
82
|
+
paddingHorizontal: s.paddingH,
|
|
83
|
+
backgroundColor: v.bg,
|
|
84
|
+
borderRadius: radius.xl,
|
|
85
|
+
opacity: inactive ? 0.5 : 1,
|
|
86
|
+
transform: [{ scale }],
|
|
87
|
+
},
|
|
88
|
+
'border' in v && { borderWidth: 1, borderColor: v.border },
|
|
89
|
+
]}
|
|
90
|
+
>
|
|
91
|
+
{loading ? (
|
|
92
|
+
<ActivityIndicator size="small" color={v.text} />
|
|
93
|
+
) : (
|
|
94
|
+
<View style={styles.content}>
|
|
95
|
+
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
|
|
96
|
+
<Text style={[styles.label, { fontSize: s.fontSize, color: v.text }]}>
|
|
97
|
+
{label.toUpperCase()}
|
|
98
|
+
</Text>
|
|
99
|
+
{rightIcon && <View style={styles.iconRight}>{rightIcon}</View>}
|
|
100
|
+
</View>
|
|
101
|
+
)}
|
|
102
|
+
</Animated.View>
|
|
103
|
+
</Pressable>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const styles = StyleSheet.create({
|
|
108
|
+
pressable: { alignSelf: 'flex-start' },
|
|
109
|
+
fullWidth: { alignSelf: 'stretch' },
|
|
110
|
+
face: { alignItems: 'center', justifyContent: 'center' },
|
|
111
|
+
content: { flexDirection: 'row', alignItems: 'center', gap: space[2] },
|
|
112
|
+
label: { fontWeight: '800', letterSpacing: 0.8 },
|
|
113
|
+
iconLeft: {},
|
|
114
|
+
iconRight: {},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export default Button;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
View,
|
|
7
|
+
StyleProp,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { semantic, space, radius } from '../tokens';
|
|
11
|
+
|
|
12
|
+
export type CardVariant = 'default' | 'elevated';
|
|
13
|
+
export type CardPadding = 'default' | 'none';
|
|
14
|
+
|
|
15
|
+
export interface CardProps {
|
|
16
|
+
variant?: CardVariant;
|
|
17
|
+
padding?: CardPadding;
|
|
18
|
+
onPress?: () => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PADDING_MAP = { default: space[4], none: 0 };
|
|
25
|
+
|
|
26
|
+
export function Card({
|
|
27
|
+
variant = 'default',
|
|
28
|
+
padding: paddingProp = 'default',
|
|
29
|
+
onPress,
|
|
30
|
+
disabled = false,
|
|
31
|
+
children,
|
|
32
|
+
style,
|
|
33
|
+
}: CardProps) {
|
|
34
|
+
const cardPadding = PADDING_MAP[paddingProp];
|
|
35
|
+
const interactive = !!onPress && !disabled;
|
|
36
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
37
|
+
|
|
38
|
+
const pressIn = () => {
|
|
39
|
+
if (!interactive) return;
|
|
40
|
+
Animated.timing(scale, { toValue: 0.98, duration: 100, useNativeDriver: true }).start();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const pressOut = () => {
|
|
44
|
+
if (!interactive) return;
|
|
45
|
+
Animated.spring(scale, { toValue: 1, friction: 8, tension: 200, useNativeDriver: true }).start();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const cardContent = (
|
|
49
|
+
<Animated.View
|
|
50
|
+
style={[
|
|
51
|
+
styles.face,
|
|
52
|
+
{
|
|
53
|
+
backgroundColor: variant === 'elevated' ? semantic.surfaceElevated : semantic.surface,
|
|
54
|
+
borderRadius: radius.lg,
|
|
55
|
+
padding: cardPadding,
|
|
56
|
+
opacity: disabled ? 0.5 : 1,
|
|
57
|
+
transform: [{ scale }],
|
|
58
|
+
},
|
|
59
|
+
style,
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</Animated.View>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (onPress) {
|
|
67
|
+
return (
|
|
68
|
+
<Pressable
|
|
69
|
+
onPress={disabled ? undefined : onPress}
|
|
70
|
+
onPressIn={pressIn}
|
|
71
|
+
onPressOut={pressOut}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
>
|
|
74
|
+
{cardContent}
|
|
75
|
+
</Pressable>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return cardContent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const styles = StyleSheet.create({
|
|
83
|
+
face: { overflow: 'hidden' },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export default Card;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, StyleSheet, View, StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import { color } from '../tokens';
|
|
4
|
+
|
|
5
|
+
export type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
6
|
+
|
|
7
|
+
export interface SpinnerProps {
|
|
8
|
+
size?: SpinnerSize;
|
|
9
|
+
color?: string;
|
|
10
|
+
style?: StyleProp<ViewStyle>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SIZE_STYLE = {
|
|
14
|
+
sm: { diameter: 16, stroke: 2 },
|
|
15
|
+
md: { diameter: 24, stroke: 3 },
|
|
16
|
+
lg: { diameter: 40, stroke: 4 },
|
|
17
|
+
xl: { diameter: 80, stroke: 5 },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Spinner({ size = 'md', color: spinnerColor = color.cyan[400], style }: SpinnerProps) {
|
|
21
|
+
const s = SIZE_STYLE[size];
|
|
22
|
+
const rotation = useRef(new Animated.Value(0)).current;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
Animated.loop(
|
|
26
|
+
Animated.timing(rotation, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }),
|
|
27
|
+
).start();
|
|
28
|
+
return () => rotation.stopAnimation();
|
|
29
|
+
}, [rotation]);
|
|
30
|
+
|
|
31
|
+
const spin = rotation.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] });
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Animated.View
|
|
35
|
+
style={[{ width: s.diameter, height: s.diameter, transform: [{ rotate: spin }] }, style]}
|
|
36
|
+
accessibilityLabel="Loading"
|
|
37
|
+
>
|
|
38
|
+
<View
|
|
39
|
+
style={[
|
|
40
|
+
styles.circle,
|
|
41
|
+
{ width: s.diameter, height: s.diameter, borderRadius: s.diameter / 2, borderWidth: s.stroke, borderColor: 'rgba(255,255,255,0.1)' },
|
|
42
|
+
]}
|
|
43
|
+
/>
|
|
44
|
+
<View
|
|
45
|
+
style={[
|
|
46
|
+
styles.arc,
|
|
47
|
+
{
|
|
48
|
+
width: s.diameter,
|
|
49
|
+
height: s.diameter,
|
|
50
|
+
borderRadius: s.diameter / 2,
|
|
51
|
+
borderWidth: s.stroke,
|
|
52
|
+
borderTopColor: spinnerColor,
|
|
53
|
+
borderRightColor: spinnerColor,
|
|
54
|
+
borderBottomColor: 'transparent',
|
|
55
|
+
borderLeftColor: 'transparent',
|
|
56
|
+
},
|
|
57
|
+
]}
|
|
58
|
+
/>
|
|
59
|
+
</Animated.View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
circle: { position: 'absolute' },
|
|
65
|
+
arc: { position: 'absolute' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export default Spinner;
|