elsabro 2.0.1 → 2.2.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/commands/elsabro/add-phase.md +17 -0
- package/commands/elsabro/add-todo.md +111 -53
- package/commands/elsabro/audit-milestone.md +19 -0
- package/commands/elsabro/check-todos.md +210 -31
- package/commands/elsabro/complete-milestone.md +20 -1
- package/commands/elsabro/debug.md +19 -0
- package/commands/elsabro/discuss-phase.md +18 -1
- package/commands/elsabro/execute.md +496 -52
- package/commands/elsabro/insert-phase.md +18 -1
- package/commands/elsabro/list-phase-assumptions.md +17 -0
- package/commands/elsabro/new-milestone.md +19 -0
- package/commands/elsabro/new.md +19 -0
- package/commands/elsabro/pause-work.md +75 -0
- package/commands/elsabro/plan-milestone-gaps.md +20 -1
- package/commands/elsabro/plan.md +264 -36
- package/commands/elsabro/progress.md +203 -79
- package/commands/elsabro/quick.md +19 -0
- package/commands/elsabro/remove-phase.md +17 -0
- package/commands/elsabro/research-phase.md +18 -1
- package/commands/elsabro/resume-work.md +130 -2
- package/commands/elsabro/start.md +365 -98
- package/commands/elsabro/verify-work.md +271 -12
- package/package.json +1 -1
- package/references/SYSTEM_INDEX.md +241 -0
- package/references/command-flow.md +352 -0
- package/references/enforcement-rules.md +331 -0
- package/references/error-handling-instructions.md +26 -12
- package/references/state-sync.md +381 -0
- package/references/task-dispatcher.md +388 -0
- package/references/tasks-integration.md +380 -0
- package/skills/api-microservice.md +765 -0
- package/skills/api-setup.md +76 -3
- package/skills/auth-setup.md +46 -6
- package/skills/chrome-extension.md +584 -0
- package/skills/cicd-setup.md +1206 -0
- package/skills/cli-tool.md +884 -0
- package/skills/database-setup.md +41 -5
- package/skills/desktop-app.md +1351 -0
- package/skills/expo-app.md +35 -2
- package/skills/full-stack-app.md +543 -0
- package/skills/mobile-app.md +813 -0
- package/skills/nextjs-app.md +33 -2
- package/skills/payments-setup.md +76 -1
- package/skills/saas-starter.md +639 -0
- package/skills/sentry-setup.md +41 -7
- package/skills/testing-setup.md +1218 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-app
|
|
3
|
+
description: Crear app movil con Expo SDK 52+, React Navigation, Expo Router, NativeWind para styling, push notifications y deep linking.
|
|
4
|
+
tags: [expo, react-native, mobile, ios, android, nativewind, push-notifications]
|
|
5
|
+
difficulty: intermediate
|
|
6
|
+
estimated_time: 45min
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skill: Mobile App
|
|
10
|
+
|
|
11
|
+
<when_to_use>
|
|
12
|
+
Usar cuando el usuario menciona:
|
|
13
|
+
- "app movil"
|
|
14
|
+
- "app para iOS/Android"
|
|
15
|
+
- "React Native"
|
|
16
|
+
- "Expo"
|
|
17
|
+
- "aplicacion nativa"
|
|
18
|
+
</when_to_use>
|
|
19
|
+
|
|
20
|
+
<pre_requisites>
|
|
21
|
+
## Pre-requisitos
|
|
22
|
+
|
|
23
|
+
- Node.js 20+
|
|
24
|
+
- Expo CLI: `npm install -g expo-cli`
|
|
25
|
+
- Expo Go app en dispositivo (para testing)
|
|
26
|
+
- Para builds nativos: Xcode (iOS) y/o Android Studio
|
|
27
|
+
</pre_requisites>
|
|
28
|
+
|
|
29
|
+
<project_structure>
|
|
30
|
+
## Estructura de Proyecto
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
my-mobile-app/
|
|
34
|
+
├── app/
|
|
35
|
+
│ ├── (tabs)/
|
|
36
|
+
│ │ ├── _layout.tsx # Tab navigator
|
|
37
|
+
│ │ ├── index.tsx # Home tab
|
|
38
|
+
│ │ ├── explore.tsx # Explore tab
|
|
39
|
+
│ │ └── profile.tsx # Profile tab
|
|
40
|
+
│ ├── (auth)/
|
|
41
|
+
│ │ ├── login.tsx
|
|
42
|
+
│ │ └── register.tsx
|
|
43
|
+
│ ├── [id].tsx # Dynamic route
|
|
44
|
+
│ ├── _layout.tsx # Root layout
|
|
45
|
+
│ └── +not-found.tsx # 404 page
|
|
46
|
+
├── components/
|
|
47
|
+
│ ├── ui/
|
|
48
|
+
│ │ ├── Button.tsx
|
|
49
|
+
│ │ ├── Input.tsx
|
|
50
|
+
│ │ └── Card.tsx
|
|
51
|
+
│ └── ThemedView.tsx
|
|
52
|
+
├── hooks/
|
|
53
|
+
│ ├── useAuth.ts
|
|
54
|
+
│ └── useNotifications.ts
|
|
55
|
+
├── stores/
|
|
56
|
+
│ └── authStore.ts # Zustand store
|
|
57
|
+
├── services/
|
|
58
|
+
│ └── api.ts # API client
|
|
59
|
+
├── constants/
|
|
60
|
+
│ └── Colors.ts
|
|
61
|
+
├── assets/
|
|
62
|
+
│ ├── fonts/
|
|
63
|
+
│ └── images/
|
|
64
|
+
├── app.json # Expo config
|
|
65
|
+
├── tailwind.config.js # NativeWind config
|
|
66
|
+
├── babel.config.js
|
|
67
|
+
├── metro.config.js
|
|
68
|
+
└── package.json
|
|
69
|
+
```
|
|
70
|
+
</project_structure>
|
|
71
|
+
|
|
72
|
+
<setup_steps>
|
|
73
|
+
## Pasos de Setup
|
|
74
|
+
|
|
75
|
+
### Paso 1: Crear proyecto Expo
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx create-expo-app@latest my-mobile-app --template tabs
|
|
79
|
+
cd my-mobile-app
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Paso 2: Instalar dependencias
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# NativeWind (Tailwind para React Native)
|
|
86
|
+
npm install nativewind tailwindcss
|
|
87
|
+
npx tailwindcss init
|
|
88
|
+
|
|
89
|
+
# Estado global
|
|
90
|
+
npm install zustand
|
|
91
|
+
|
|
92
|
+
# Navegacion adicional
|
|
93
|
+
npm install @react-navigation/native @react-navigation/stack
|
|
94
|
+
|
|
95
|
+
# Storage persistente
|
|
96
|
+
npm install @react-native-async-storage/async-storage
|
|
97
|
+
|
|
98
|
+
# Push notifications
|
|
99
|
+
npx expo install expo-notifications expo-device expo-constants
|
|
100
|
+
|
|
101
|
+
# Linking
|
|
102
|
+
npx expo install expo-linking
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Paso 3: Configurar NativeWind
|
|
106
|
+
|
|
107
|
+
Editar `tailwind.config.js`:
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
/** @type {import('tailwindcss').Config} */
|
|
111
|
+
module.exports = {
|
|
112
|
+
content: [
|
|
113
|
+
"./app/**/*.{js,jsx,ts,tsx}",
|
|
114
|
+
"./components/**/*.{js,jsx,ts,tsx}",
|
|
115
|
+
],
|
|
116
|
+
presets: [require("nativewind/preset")],
|
|
117
|
+
theme: {
|
|
118
|
+
extend: {
|
|
119
|
+
colors: {
|
|
120
|
+
primary: "#007AFF",
|
|
121
|
+
secondary: "#5856D6",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
plugins: [],
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Editar `babel.config.js`:
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
module.exports = function (api) {
|
|
133
|
+
api.cache(true);
|
|
134
|
+
return {
|
|
135
|
+
presets: [
|
|
136
|
+
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
|
137
|
+
"nativewind/babel",
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Crear `global.css`:
|
|
144
|
+
|
|
145
|
+
```css
|
|
146
|
+
@tailwind base;
|
|
147
|
+
@tailwind components;
|
|
148
|
+
@tailwind utilities;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Actualizar `app/_layout.tsx`:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import "../global.css";
|
|
155
|
+
import { Stack } from "expo-router";
|
|
156
|
+
import { useColorScheme } from "react-native";
|
|
157
|
+
|
|
158
|
+
export default function RootLayout() {
|
|
159
|
+
const colorScheme = useColorScheme();
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Stack
|
|
163
|
+
screenOptions={{
|
|
164
|
+
headerStyle: {
|
|
165
|
+
backgroundColor: colorScheme === "dark" ? "#000" : "#fff",
|
|
166
|
+
},
|
|
167
|
+
headerTintColor: colorScheme === "dark" ? "#fff" : "#000",
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
171
|
+
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
|
172
|
+
</Stack>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Paso 4: Crear store de autenticacion
|
|
178
|
+
|
|
179
|
+
Crear `stores/authStore.ts`:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { create } from "zustand";
|
|
183
|
+
import { persist, createJSONStorage } from "zustand/middleware";
|
|
184
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
185
|
+
|
|
186
|
+
interface User {
|
|
187
|
+
id: string;
|
|
188
|
+
email: string;
|
|
189
|
+
name: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface AuthState {
|
|
193
|
+
user: User | null;
|
|
194
|
+
token: string | null;
|
|
195
|
+
isLoading: boolean;
|
|
196
|
+
login: (email: string, password: string) => Promise<void>;
|
|
197
|
+
logout: () => void;
|
|
198
|
+
setUser: (user: User) => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const useAuthStore = create<AuthState>()(
|
|
202
|
+
persist(
|
|
203
|
+
(set) => ({
|
|
204
|
+
user: null,
|
|
205
|
+
token: null,
|
|
206
|
+
isLoading: false,
|
|
207
|
+
|
|
208
|
+
login: async (email, password) => {
|
|
209
|
+
set({ isLoading: true });
|
|
210
|
+
try {
|
|
211
|
+
// Llamar a tu API
|
|
212
|
+
const response = await fetch("https://api.example.com/login", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify({ email, password }),
|
|
216
|
+
});
|
|
217
|
+
const data = await response.json();
|
|
218
|
+
|
|
219
|
+
set({
|
|
220
|
+
user: data.user,
|
|
221
|
+
token: data.token,
|
|
222
|
+
isLoading: false,
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
set({ isLoading: false });
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
logout: () => {
|
|
231
|
+
set({ user: null, token: null });
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
setUser: (user) => set({ user }),
|
|
235
|
+
}),
|
|
236
|
+
{
|
|
237
|
+
name: "auth-storage",
|
|
238
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Paso 5: Crear componentes UI
|
|
245
|
+
|
|
246
|
+
Crear `components/ui/Button.tsx`:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import { TouchableOpacity, Text, ActivityIndicator } from "react-native";
|
|
250
|
+
|
|
251
|
+
interface ButtonProps {
|
|
252
|
+
title: string;
|
|
253
|
+
onPress: () => void;
|
|
254
|
+
variant?: "primary" | "secondary" | "outline";
|
|
255
|
+
loading?: boolean;
|
|
256
|
+
disabled?: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function Button({
|
|
260
|
+
title,
|
|
261
|
+
onPress,
|
|
262
|
+
variant = "primary",
|
|
263
|
+
loading = false,
|
|
264
|
+
disabled = false,
|
|
265
|
+
}: ButtonProps) {
|
|
266
|
+
const baseClasses = "py-3 px-6 rounded-xl items-center justify-center";
|
|
267
|
+
|
|
268
|
+
const variantClasses = {
|
|
269
|
+
primary: "bg-primary",
|
|
270
|
+
secondary: "bg-secondary",
|
|
271
|
+
outline: "border-2 border-primary bg-transparent",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const textClasses = {
|
|
275
|
+
primary: "text-white font-semibold text-base",
|
|
276
|
+
secondary: "text-white font-semibold text-base",
|
|
277
|
+
outline: "text-primary font-semibold text-base",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<TouchableOpacity
|
|
282
|
+
className={`${baseClasses} ${variantClasses[variant]} ${
|
|
283
|
+
disabled ? "opacity-50" : ""
|
|
284
|
+
}`}
|
|
285
|
+
onPress={onPress}
|
|
286
|
+
disabled={disabled || loading}
|
|
287
|
+
activeOpacity={0.8}
|
|
288
|
+
>
|
|
289
|
+
{loading ? (
|
|
290
|
+
<ActivityIndicator color={variant === "outline" ? "#007AFF" : "#fff"} />
|
|
291
|
+
) : (
|
|
292
|
+
<Text className={textClasses[variant]}>{title}</Text>
|
|
293
|
+
)}
|
|
294
|
+
</TouchableOpacity>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Crear `components/ui/Input.tsx`:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
import { View, TextInput, Text } from "react-native";
|
|
303
|
+
import { useState } from "react";
|
|
304
|
+
|
|
305
|
+
interface InputProps {
|
|
306
|
+
label?: string;
|
|
307
|
+
placeholder?: string;
|
|
308
|
+
value: string;
|
|
309
|
+
onChangeText: (text: string) => void;
|
|
310
|
+
secureTextEntry?: boolean;
|
|
311
|
+
error?: string;
|
|
312
|
+
keyboardType?: "default" | "email-address" | "numeric";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function Input({
|
|
316
|
+
label,
|
|
317
|
+
placeholder,
|
|
318
|
+
value,
|
|
319
|
+
onChangeText,
|
|
320
|
+
secureTextEntry = false,
|
|
321
|
+
error,
|
|
322
|
+
keyboardType = "default",
|
|
323
|
+
}: InputProps) {
|
|
324
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<View className="mb-4">
|
|
328
|
+
{label && (
|
|
329
|
+
<Text className="text-gray-700 font-medium mb-1 dark:text-gray-300">
|
|
330
|
+
{label}
|
|
331
|
+
</Text>
|
|
332
|
+
)}
|
|
333
|
+
<TextInput
|
|
334
|
+
className={`bg-gray-100 dark:bg-gray-800 px-4 py-3 rounded-xl text-base ${
|
|
335
|
+
isFocused ? "border-2 border-primary" : "border-2 border-transparent"
|
|
336
|
+
} ${error ? "border-red-500" : ""}`}
|
|
337
|
+
placeholder={placeholder}
|
|
338
|
+
placeholderTextColor="#9CA3AF"
|
|
339
|
+
value={value}
|
|
340
|
+
onChangeText={onChangeText}
|
|
341
|
+
secureTextEntry={secureTextEntry}
|
|
342
|
+
keyboardType={keyboardType}
|
|
343
|
+
onFocus={() => setIsFocused(true)}
|
|
344
|
+
onBlur={() => setIsFocused(false)}
|
|
345
|
+
/>
|
|
346
|
+
{error && <Text className="text-red-500 text-sm mt-1">{error}</Text>}
|
|
347
|
+
</View>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Paso 6: Crear pantalla de login
|
|
353
|
+
|
|
354
|
+
Crear `app/(auth)/login.tsx`:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { View, Text, KeyboardAvoidingView, Platform } from "react-native";
|
|
358
|
+
import { useState } from "react";
|
|
359
|
+
import { router } from "expo-router";
|
|
360
|
+
import { useAuthStore } from "@/stores/authStore";
|
|
361
|
+
import { Button } from "@/components/ui/Button";
|
|
362
|
+
import { Input } from "@/components/ui/Input";
|
|
363
|
+
|
|
364
|
+
export default function LoginScreen() {
|
|
365
|
+
const [email, setEmail] = useState("");
|
|
366
|
+
const [password, setPassword] = useState("");
|
|
367
|
+
const [error, setError] = useState("");
|
|
368
|
+
|
|
369
|
+
const { login, isLoading } = useAuthStore();
|
|
370
|
+
|
|
371
|
+
const handleLogin = async () => {
|
|
372
|
+
try {
|
|
373
|
+
setError("");
|
|
374
|
+
await login(email, password);
|
|
375
|
+
router.replace("/(tabs)");
|
|
376
|
+
} catch (err) {
|
|
377
|
+
setError("Credenciales invalidas");
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<KeyboardAvoidingView
|
|
383
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
384
|
+
className="flex-1 bg-white dark:bg-gray-900"
|
|
385
|
+
>
|
|
386
|
+
<View className="flex-1 justify-center px-6">
|
|
387
|
+
<Text className="text-3xl font-bold text-center mb-8 dark:text-white">
|
|
388
|
+
Bienvenido
|
|
389
|
+
</Text>
|
|
390
|
+
|
|
391
|
+
{error && (
|
|
392
|
+
<View className="bg-red-100 p-3 rounded-lg mb-4">
|
|
393
|
+
<Text className="text-red-600 text-center">{error}</Text>
|
|
394
|
+
</View>
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
<Input
|
|
398
|
+
label="Email"
|
|
399
|
+
placeholder="tu@email.com"
|
|
400
|
+
value={email}
|
|
401
|
+
onChangeText={setEmail}
|
|
402
|
+
keyboardType="email-address"
|
|
403
|
+
/>
|
|
404
|
+
|
|
405
|
+
<Input
|
|
406
|
+
label="Password"
|
|
407
|
+
placeholder="********"
|
|
408
|
+
value={password}
|
|
409
|
+
onChangeText={setPassword}
|
|
410
|
+
secureTextEntry
|
|
411
|
+
/>
|
|
412
|
+
|
|
413
|
+
<View className="mt-6">
|
|
414
|
+
<Button
|
|
415
|
+
title="Iniciar Sesion"
|
|
416
|
+
onPress={handleLogin}
|
|
417
|
+
loading={isLoading}
|
|
418
|
+
/>
|
|
419
|
+
</View>
|
|
420
|
+
|
|
421
|
+
<Text
|
|
422
|
+
className="text-center mt-4 text-gray-600 dark:text-gray-400"
|
|
423
|
+
onPress={() => router.push("/(auth)/register")}
|
|
424
|
+
>
|
|
425
|
+
No tienes cuenta?{" "}
|
|
426
|
+
<Text className="text-primary font-semibold">Registrate</Text>
|
|
427
|
+
</Text>
|
|
428
|
+
</View>
|
|
429
|
+
</KeyboardAvoidingView>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Paso 7: Configurar push notifications
|
|
435
|
+
|
|
436
|
+
Crear `hooks/useNotifications.ts`:
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
import { useEffect, useRef, useState } from "react";
|
|
440
|
+
import * as Notifications from "expo-notifications";
|
|
441
|
+
import * as Device from "expo-device";
|
|
442
|
+
import Constants from "expo-constants";
|
|
443
|
+
import { Platform } from "react-native";
|
|
444
|
+
|
|
445
|
+
Notifications.setNotificationHandler({
|
|
446
|
+
handleNotification: async () => ({
|
|
447
|
+
shouldShowAlert: true,
|
|
448
|
+
shouldPlaySound: true,
|
|
449
|
+
shouldSetBadge: true,
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
export function useNotifications() {
|
|
454
|
+
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
|
|
455
|
+
const [notification, setNotification] =
|
|
456
|
+
useState<Notifications.Notification | null>(null);
|
|
457
|
+
const notificationListener = useRef<Notifications.Subscription>();
|
|
458
|
+
const responseListener = useRef<Notifications.Subscription>();
|
|
459
|
+
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
registerForPushNotificationsAsync().then((token) => {
|
|
462
|
+
if (token) setExpoPushToken(token);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
notificationListener.current =
|
|
466
|
+
Notifications.addNotificationReceivedListener((notification) => {
|
|
467
|
+
setNotification(notification);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
responseListener.current =
|
|
471
|
+
Notifications.addNotificationResponseReceivedListener((response) => {
|
|
472
|
+
// Manejar cuando el usuario toca la notificacion
|
|
473
|
+
console.log(response);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return () => {
|
|
477
|
+
if (notificationListener.current) {
|
|
478
|
+
Notifications.removeNotificationSubscription(
|
|
479
|
+
notificationListener.current
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (responseListener.current) {
|
|
483
|
+
Notifications.removeNotificationSubscription(responseListener.current);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}, []);
|
|
487
|
+
|
|
488
|
+
return { expoPushToken, notification };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function registerForPushNotificationsAsync() {
|
|
492
|
+
let token;
|
|
493
|
+
|
|
494
|
+
if (Platform.OS === "android") {
|
|
495
|
+
await Notifications.setNotificationChannelAsync("default", {
|
|
496
|
+
name: "default",
|
|
497
|
+
importance: Notifications.AndroidImportance.MAX,
|
|
498
|
+
vibrationPattern: [0, 250, 250, 250],
|
|
499
|
+
lightColor: "#FF231F7C",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (Device.isDevice) {
|
|
504
|
+
const { status: existingStatus } =
|
|
505
|
+
await Notifications.getPermissionsAsync();
|
|
506
|
+
let finalStatus = existingStatus;
|
|
507
|
+
|
|
508
|
+
if (existingStatus !== "granted") {
|
|
509
|
+
const { status } = await Notifications.requestPermissionsAsync();
|
|
510
|
+
finalStatus = status;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (finalStatus !== "granted") {
|
|
514
|
+
console.log("Failed to get push token");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
|
519
|
+
token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return token;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Funcion para enviar notificacion local
|
|
526
|
+
export async function sendLocalNotification(
|
|
527
|
+
title: string,
|
|
528
|
+
body: string,
|
|
529
|
+
data?: Record<string, unknown>
|
|
530
|
+
) {
|
|
531
|
+
await Notifications.scheduleNotificationAsync({
|
|
532
|
+
content: {
|
|
533
|
+
title,
|
|
534
|
+
body,
|
|
535
|
+
data,
|
|
536
|
+
},
|
|
537
|
+
trigger: null, // Inmediato
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Paso 8: Configurar deep linking
|
|
543
|
+
|
|
544
|
+
Actualizar `app.json`:
|
|
545
|
+
|
|
546
|
+
```json
|
|
547
|
+
{
|
|
548
|
+
"expo": {
|
|
549
|
+
"name": "my-mobile-app",
|
|
550
|
+
"slug": "my-mobile-app",
|
|
551
|
+
"scheme": "myapp",
|
|
552
|
+
"ios": {
|
|
553
|
+
"bundleIdentifier": "com.yourcompany.myapp",
|
|
554
|
+
"associatedDomains": ["applinks:yourapp.com"]
|
|
555
|
+
},
|
|
556
|
+
"android": {
|
|
557
|
+
"package": "com.yourcompany.myapp",
|
|
558
|
+
"intentFilters": [
|
|
559
|
+
{
|
|
560
|
+
"action": "VIEW",
|
|
561
|
+
"autoVerify": true,
|
|
562
|
+
"data": [
|
|
563
|
+
{
|
|
564
|
+
"scheme": "https",
|
|
565
|
+
"host": "yourapp.com",
|
|
566
|
+
"pathPrefix": "/"
|
|
567
|
+
}
|
|
568
|
+
],
|
|
569
|
+
"category": ["BROWSABLE", "DEFAULT"]
|
|
570
|
+
}
|
|
571
|
+
]
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Crear `app/[id].tsx` para rutas dinamicas:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
import { View, Text } from "react-native";
|
|
581
|
+
import { useLocalSearchParams } from "expo-router";
|
|
582
|
+
|
|
583
|
+
export default function DetailScreen() {
|
|
584
|
+
const { id } = useLocalSearchParams();
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<View className="flex-1 items-center justify-center bg-white dark:bg-gray-900">
|
|
588
|
+
<Text className="text-xl dark:text-white">Detalle: {id}</Text>
|
|
589
|
+
</View>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Paso 9: Crear servicio de API
|
|
595
|
+
|
|
596
|
+
Crear `services/api.ts`:
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
const API_URL = "https://api.example.com";
|
|
600
|
+
|
|
601
|
+
class ApiService {
|
|
602
|
+
private token: string | null = null;
|
|
603
|
+
|
|
604
|
+
setToken(token: string | null) {
|
|
605
|
+
this.token = token;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private async request<T>(
|
|
609
|
+
endpoint: string,
|
|
610
|
+
options: RequestInit = {}
|
|
611
|
+
): Promise<T> {
|
|
612
|
+
const headers: HeadersInit = {
|
|
613
|
+
"Content-Type": "application/json",
|
|
614
|
+
...options.headers,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
if (this.token) {
|
|
618
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
622
|
+
...options,
|
|
623
|
+
headers,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
if (!response.ok) {
|
|
627
|
+
throw new Error(`API Error: ${response.status}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return response.json();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Metodos de la API
|
|
634
|
+
async getItems() {
|
|
635
|
+
return this.request<Item[]>("/items");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async getItem(id: string) {
|
|
639
|
+
return this.request<Item>(`/items/${id}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async createItem(data: CreateItemInput) {
|
|
643
|
+
return this.request<Item>("/items", {
|
|
644
|
+
method: "POST",
|
|
645
|
+
body: JSON.stringify(data),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const api = new ApiService();
|
|
651
|
+
|
|
652
|
+
// Types
|
|
653
|
+
interface Item {
|
|
654
|
+
id: string;
|
|
655
|
+
title: string;
|
|
656
|
+
description: string;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
interface CreateItemInput {
|
|
660
|
+
title: string;
|
|
661
|
+
description: string;
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### Paso 10: Tab Home con lista
|
|
666
|
+
|
|
667
|
+
Actualizar `app/(tabs)/index.tsx`:
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
import { View, Text, FlatList, RefreshControl } from "react-native";
|
|
671
|
+
import { useState, useCallback } from "react";
|
|
672
|
+
import { Button } from "@/components/ui/Button";
|
|
673
|
+
import { router } from "expo-router";
|
|
674
|
+
|
|
675
|
+
interface Item {
|
|
676
|
+
id: string;
|
|
677
|
+
title: string;
|
|
678
|
+
description: string;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export default function HomeScreen() {
|
|
682
|
+
const [items, setItems] = useState<Item[]>([
|
|
683
|
+
{ id: "1", title: "Primer item", description: "Descripcion del item" },
|
|
684
|
+
{ id: "2", title: "Segundo item", description: "Otra descripcion" },
|
|
685
|
+
]);
|
|
686
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
687
|
+
|
|
688
|
+
const onRefresh = useCallback(async () => {
|
|
689
|
+
setRefreshing(true);
|
|
690
|
+
// Simular fetch
|
|
691
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
692
|
+
setRefreshing(false);
|
|
693
|
+
}, []);
|
|
694
|
+
|
|
695
|
+
const renderItem = ({ item }: { item: Item }) => (
|
|
696
|
+
<View
|
|
697
|
+
className="bg-white dark:bg-gray-800 p-4 rounded-xl mb-3 shadow-sm"
|
|
698
|
+
onTouchEnd={() => router.push(`/${item.id}`)}
|
|
699
|
+
>
|
|
700
|
+
<Text className="text-lg font-semibold dark:text-white">
|
|
701
|
+
{item.title}
|
|
702
|
+
</Text>
|
|
703
|
+
<Text className="text-gray-600 dark:text-gray-400 mt-1">
|
|
704
|
+
{item.description}
|
|
705
|
+
</Text>
|
|
706
|
+
</View>
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
|
|
711
|
+
<FlatList
|
|
712
|
+
data={items}
|
|
713
|
+
renderItem={renderItem}
|
|
714
|
+
keyExtractor={(item) => item.id}
|
|
715
|
+
contentContainerClassName="p-4"
|
|
716
|
+
refreshControl={
|
|
717
|
+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
|
718
|
+
}
|
|
719
|
+
ListEmptyComponent={
|
|
720
|
+
<View className="items-center py-8">
|
|
721
|
+
<Text className="text-gray-500">No hay items</Text>
|
|
722
|
+
</View>
|
|
723
|
+
}
|
|
724
|
+
/>
|
|
725
|
+
|
|
726
|
+
<View className="p-4">
|
|
727
|
+
<Button
|
|
728
|
+
title="Agregar Item"
|
|
729
|
+
onPress={() => console.log("Add item")}
|
|
730
|
+
/>
|
|
731
|
+
</View>
|
|
732
|
+
</View>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
</setup_steps>
|
|
737
|
+
|
|
738
|
+
<verification>
|
|
739
|
+
## Verificacion
|
|
740
|
+
|
|
741
|
+
### 1. Iniciar app
|
|
742
|
+
```bash
|
|
743
|
+
npx expo start
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### 2. Probar en Expo Go
|
|
747
|
+
- Escanear QR con Expo Go
|
|
748
|
+
- Navegar entre tabs
|
|
749
|
+
- Probar dark mode
|
|
750
|
+
|
|
751
|
+
### 3. Verificar NativeWind
|
|
752
|
+
- Los estilos de Tailwind deben aplicar
|
|
753
|
+
- Dark mode debe funcionar
|
|
754
|
+
|
|
755
|
+
### 4. Probar deep link
|
|
756
|
+
```bash
|
|
757
|
+
# iOS Simulator
|
|
758
|
+
npx uri-scheme open myapp://1 --ios
|
|
759
|
+
|
|
760
|
+
# Android Emulator
|
|
761
|
+
npx uri-scheme open myapp://1 --android
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### 5. Build de desarrollo
|
|
765
|
+
```bash
|
|
766
|
+
npx expo run:ios
|
|
767
|
+
# o
|
|
768
|
+
npx expo run:android
|
|
769
|
+
```
|
|
770
|
+
</verification>
|
|
771
|
+
|
|
772
|
+
<common_issues>
|
|
773
|
+
## Problemas Comunes
|
|
774
|
+
|
|
775
|
+
### "NativeWind styles not applying"
|
|
776
|
+
- Verificar babel.config.js
|
|
777
|
+
- Reiniciar metro bundler: `npx expo start -c`
|
|
778
|
+
|
|
779
|
+
### "Push notifications not working in Expo Go"
|
|
780
|
+
- Las push notifications requieren build nativo
|
|
781
|
+
- Usar `expo-dev-client` para testing completo
|
|
782
|
+
|
|
783
|
+
### "Deep links not working"
|
|
784
|
+
- Verificar scheme en app.json
|
|
785
|
+
- Reinstalar la app despues de cambiar config
|
|
786
|
+
</common_issues>
|
|
787
|
+
|
|
788
|
+
<builds>
|
|
789
|
+
## Builds para Stores
|
|
790
|
+
|
|
791
|
+
### Configurar EAS Build
|
|
792
|
+
```bash
|
|
793
|
+
npm install -g eas-cli
|
|
794
|
+
eas login
|
|
795
|
+
eas build:configure
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
### Build iOS
|
|
799
|
+
```bash
|
|
800
|
+
eas build --platform ios
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
### Build Android
|
|
804
|
+
```bash
|
|
805
|
+
eas build --platform android
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### Submit a stores
|
|
809
|
+
```bash
|
|
810
|
+
eas submit --platform ios
|
|
811
|
+
eas submit --platform android
|
|
812
|
+
```
|
|
813
|
+
</builds>
|