ตั้งค่า LynxJS Mobile App สำหรับ IoT
ตั้งค่า LynxJS Mobile App สำหรับ IoT
Branch:
workshop/dev-10-lynxjs-setupPhase: Mobile Frontend (1/5) Repo: kangana1024/iot-workshop
Hook: ทำไมต้องมี Mobile App?
น้องๆ ลองนึกภาพดูนะ — เซ็นเซอร์ IoT ติดทั่วออฟฟิศ แต่อยากเช็กค่าอุณหภูมิต้องนั่งเปิด laptop ทุกครั้ง… มันก็ไม่ต่างจากมีรถแต่ต้องเดินไปดูน้ำมันที่ปั๊มทุกวันเลย (╯°□°)╯
เราจะแก้ปัญหานี้ด้วยการสร้าง LynxJS Mobile App ที่หยิบมือถือขึ้นมาก็เห็นทุกอย่างได้เลย ทั้ง dashboard, device status, alerts และ settings — มาลุยกัน!
สิ่งที่น้องๆ จะได้จากบทนี้
- เข้าใจว่า ทำไม ต้องใช้ LynxJS และโครงสร้าง monorepo
- ตั้งค่าโปรเจกต์ LynxJS ใน monorepo ได้จริง
- สร้าง Tab Navigation 4 หน้าพร้อม icon
- มี Base Components ครบ: Card, Button, Badge, Input
- จัดการ Theme (Dark/Light mode) แบบมืออาชีพ
- เชื่อม API ด้วย Axios พร้อม interceptors
ทำไม LynxJS? (WHY ก่อนเสมอ)
ก่อนจะลงมือ เรามาตอบคำถามนี้กันก่อนนะ
LynxJS คือ framework สำหรับสร้าง mobile app ที่ใช้ React + TypeScript ได้เลย — เหมือนกับว่าน้องๆ รู้ React อยู่แล้ว ก็แทบไม่ต้องเรียนใหม่ แค่เปลี่ยนจาก div เป็น View, p เป็น Text เท่านั้นเอง
เปรียบเทียบง่ายๆ: ถ้า React Native คือ “ขับรถเกียร์ธรรมดา” LynxJS ก็คือ “รถเกียร์ออโต้ที่ DX ดีกว่า” — ฟีเจอร์ใกล้เคียงกัน แต่ทำงานลื่นกว่า setup ง่ายกว่า
ภาพรวมคือ: IoT Device → Backend → Mobile App — เราอยู่ฝั่ง Mobile App บทนี้
Step 1: สร้างโปรเจกต์ LynxJS
ทำไมต้องอยู่ใน Monorepo?
เพราะโปรเจกต์เรามีทั้ง backend, frontend-web, และ frontend-mobile อยู่ด้วยกัน การใช้ monorepo เหมือนกับการมีกล่องใบเดียวที่เก็บของทุกชิ้นไว้ — หยิบอะไรก็เจอง่าย share code กันได้ และ deploy พร้อมกันได้ในครั้งเดียว
1.1 Init โปรเจกต์
# อยู่ที่ root ของ monorepo
cd iot-workshop
# สร้าง LynxJS project ใน frontend-mobile
npm create lynx@latest frontend-mobile -- --template react-ts
cd frontend-mobile
npm install
1.2 ติดตั้ง Dependencies เพิ่มเติม
แต่ละกลุ่มมีหน้าที่ต่างกันชัดเจน เรา install แยกกลุ่มเพื่อให้อ่านแล้วรู้ว่าทำอะไร:
# Navigation — พาไปมาระหว่างหน้า
npm install @lynx-js/react-navigation @lynx-js/bottom-tabs
# State Management — จัดการ global state
npm install zustand immer
# API & WebSocket — คุยกับ backend
npm install axios @lynx-js/async-storage
# UI & Icons — หน้าตาสวยงาม
npm install @lynx-js/vector-icons react-native-svg
# Utilities — เครื่องมือเสริม เช่น format date
npm install date-fns
# Dev Dependencies — tools สำหรับ dev เท่านั้น
npm install -D @types/react @lynx-js/types
1.3 โครงสร้างโฟลเดอร์
น้องๆ อาจถามว่าทำไมต้องแยกโฟลเดอร์เยอะขนาดนี้? คำตอบง่ายมาก — เหมือนครัวในร้านอาหาร ถ้าวางทุกอย่างปนกันก็หาไม่เจอ แต่ถ้าจัดโซนชัดเจน (ผัก/เนื้อ/เครื่องปรุง) ทำงานได้เร็วกว่าเยอะ
frontend-mobile/
├── src/
│ ├── screens/ <- หน้าหลักแต่ละแท็บ
│ │ ├── DashboardScreen.tsx
│ │ ├── DevicesScreen.tsx
│ │ ├── AlertsScreen.tsx
│ │ └── SettingsScreen.tsx
│ ├── components/ <- ชิ้นส่วนที่ใช้ซ้ำได้
│ │ ├── ui/
│ │ │ ├── Card.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Badge.tsx
│ │ │ └── Input.tsx
│ │ └── shared/
│ │ ├── LoadingSpinner.tsx
│ │ └── ErrorBoundary.tsx
│ ├── navigation/ <- logic การนำทาง
│ │ └── TabNavigator.tsx
│ ├── services/ <- คุยกับโลกภายนอก
│ │ ├── api.ts
│ │ ├── websocket.ts
│ │ └── storage.ts
│ ├── stores/ <- state กลาง
│ │ ├── useDeviceStore.ts
│ │ ├── useAlertStore.ts
│ │ └── useSettingsStore.ts
│ ├── theme/ <- สี + ฟอนต์
│ │ ├── colors.ts
│ │ ├── typography.ts
│ │ └── index.ts
│ ├── types/ <- TypeScript types ทุกตัว
│ │ └── index.ts
│ └── utils/ <- helper functions เล็กๆ น้อยๆ
│ └── helpers.ts
├── .env
├── .env.example
├── lynx.config.ts
└── tsconfig.json
Step 2: Tab Navigation
ทำไมต้อง Tab Navigation?
Tab bar ด้านล่างมือถือคือ “แผนที่” ของ app — ผู้ใช้เห็นแล้วรู้ทันทีว่า app นี้มีอะไรบ้างและตอนนี้อยู่ที่ไหน เหมือนป้ายบอกทางในห้างที่ดี ไม่ต้องเดาว่าแผนกอาหารอยู่ชั้นไหน
( ง •̀_•́)ง App ของเรามี 4 tabs:
[Dashboard] [Devices] [Alerts] [Settings]
2.1 TabNavigator Component
// src/navigation/TabNavigator.tsx
import { createBottomTabNavigator } from '@lynx-js/bottom-tabs';
import { NavigationContainer } from '@lynx-js/react-navigation';
import { useTheme } from '../theme';
import DashboardScreen from '../screens/DashboardScreen';
import DevicesScreen from '../screens/DevicesScreen';
import AlertsScreen from '../screens/AlertsScreen';
import SettingsScreen from '../screens/SettingsScreen';
import { Icon } from '@lynx-js/vector-icons';
const Tab = createBottomTabNavigator();
export default function TabNavigator() {
const { colors } = useTheme();
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={{
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: 1,
paddingBottom: 8,
paddingTop: 4,
height: 60,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
headerTitleStyle: { fontWeight: '600' },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: 'Dashboard',
tabBarIcon: ({ color, size }) => (
<Icon name="grid-outline" color={color} size={size} />
),
}}
/>
<Tab.Screen
name="Devices"
component={DevicesScreen}
options={{
title: 'Devices',
tabBarIcon: ({ color, size }) => (
<Icon name="hardware-chip-outline" color={color} size={size} />
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: 'Alerts',
tabBarIcon: ({ color, size }) => (
<Icon name="notifications-outline" color={color} size={size} />
),
tabBarBadge: undefined, // จะ set dynamic ใน AlertsScreen
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => (
<Icon name="settings-outline" color={color} size={size} />
),
}}
/>
</Tab.Navigator>
</NavigationContainer>
);
}
สังเกตว่าเราดึง colors มาจาก useTheme() — ทำให้ tab bar เปลี่ยนสีตาม dark/light mode โดยอัตโนมัติ ไม่ต้อง hardcode สีเลย
Step 3: Base Components
ทำไมต้องทำ Base Components?
Base Components เหมือน “อิฐมาตรฐาน” ที่ใช้สร้างบ้าน — ถ้าอิฐทุกก้อนมีขนาดและสีสม่ำเสมอ บ้านที่สร้างออกมาก็จะดูสวยงามและเป็นระเบียบ แต่ถ้าแต่ละหน้า code button เองก็จะได้ button หน้าตาไม่เหมือนกันทั่ว app
3.1 Card Component
Card คือ “กล่อง” ที่ใช้ห่อ content ต่างๆ มี 3 variant:
default— กล่องธรรมดาelevated— มีเงา ดูลอยขึ้นมาoutlined— มีขอบ ไม่มีพื้นหลัง
// src/components/ui/Card.tsx
import { View, Text, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface CardProps {
title?: string;
children: React.ReactNode;
variant?: 'default' | 'elevated' | 'outlined';
padding?: number;
}
export function Card({
title,
children,
variant = 'default',
padding = 16,
}: CardProps) {
const { colors } = useTheme();
const cardStyle = {
default: {
backgroundColor: colors.surface,
borderRadius: 12,
padding,
},
elevated: {
backgroundColor: colors.surface,
borderRadius: 12,
padding,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
outlined: {
backgroundColor: 'transparent',
borderRadius: 12,
padding,
borderWidth: 1,
borderColor: colors.border,
},
};
return (
<View style={[styles.card, cardStyle[variant]]}>
{title && (
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
)}
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
marginBottom: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
},
});
3.2 Button Component
Button มี 4 รูปแบบ (primary, secondary, danger, ghost) และ 3 ขนาด (sm, md, lg) — เหมือนกาแฟที่มีทั้ง S/M/L และสั่ง hot/iced/blended ได้ด้วย
// src/components/ui/Button.tsx
import { TouchableOpacity, Text, ActivityIndicator, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface ButtonProps {
label: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
}
export function Button({
label,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
fullWidth = false,
}: ButtonProps) {
const { colors } = useTheme();
const variantStyles = {
primary: {
backgroundColor: colors.primary,
borderWidth: 0,
},
secondary: {
backgroundColor: colors.secondary,
borderWidth: 0,
},
danger: {
backgroundColor: colors.danger,
borderWidth: 0,
},
ghost: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.primary,
},
};
const sizeStyles = {
sm: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 6 },
md: { paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8 },
lg: { paddingVertical: 14, paddingHorizontal: 28, borderRadius: 10 },
};
const textSizes = { sm: 13, md: 15, lg: 17 };
return (
<TouchableOpacity
style={[
styles.button,
variantStyles[variant],
sizeStyles[size],
fullWidth && styles.fullWidth,
(disabled || loading) && styles.disabled,
]}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.8}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text
style={[
styles.label,
{
fontSize: textSizes[size],
color: variant === 'ghost' ? colors.primary : '#fff',
},
]}
>
{label}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
},
label: {
fontWeight: '600',
},
});
3.3 Badge Component
Badge คือป้ายเล็กๆ แปะสถานะ — เหมือนสติ๊กเกอร์ “ขายดี” / “หมดอายุ” / “ใหม่!” ที่เห็นในซุปเปอร์มาร์เก็ต มองแล้วรู้เรื่องทันที
// src/components/ui/Badge.tsx
import { View, Text, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'default';
interface BadgeProps {
label: string;
variant?: BadgeVariant;
size?: 'sm' | 'md';
}
export function Badge({ label, variant = 'default', size = 'md' }: BadgeProps) {
const { colors } = useTheme();
const variantColors: Record<BadgeVariant, { bg: string; text: string }> = {
success: { bg: colors.successLight, text: colors.success },
warning: { bg: colors.warningLight, text: colors.warning },
danger: { bg: colors.dangerLight, text: colors.danger },
info: { bg: colors.infoLight, text: colors.info },
default: { bg: colors.border, text: colors.textSecondary },
};
const { bg, text } = variantColors[variant];
return (
<View
style={[
styles.badge,
{ backgroundColor: bg },
size === 'sm' && styles.small,
]}
>
<Text style={[styles.text, { color: text }, size === 'sm' && styles.smallText]}>
{label}
</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
alignSelf: 'flex-start',
},
small: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
text: {
fontSize: 12,
fontWeight: '600',
},
smallText: {
fontSize: 10,
},
});
3.4 Input Component
Input ของเราฉลาดกว่า <input> ธรรมดา เพราะรู้ว่าตอนนี้ focused อยู่ไหม มี error ไหม และเปลี่ยนสีขอบตามสถานะนั้นๆ เหมือนไฟจราจรที่บอกสถานะชัดเจน
// src/components/ui/Input.tsx
import { View, Text, TextInput, StyleSheet } from '@lynx-js/react';
import { useState } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface InputProps {
label?: string;
placeholder?: string;
value: string;
onChangeText: (text: string) => void;
secureTextEntry?: boolean;
error?: string;
disabled?: boolean;
multiline?: boolean;
numberOfLines?: number;
}
export function Input({
label,
placeholder,
value,
onChangeText,
secureTextEntry = false,
error,
disabled = false,
multiline = false,
numberOfLines = 1,
}: InputProps) {
const { colors } = useTheme();
const [focused, setFocused] = useState(false);
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
</Text>
)}
<TextInput
style={[
styles.input,
{
backgroundColor: colors.inputBackground,
borderColor: error
? colors.danger
: focused
? colors.primary
: colors.border,
color: colors.text,
},
multiline && { height: numberOfLines * 40, textAlignVertical: 'top' },
disabled && { opacity: 0.5 },
]}
placeholder={placeholder}
placeholderTextColor={colors.textSecondary}
value={value}
onChangeText={onChangeText}
secureTextEntry={secureTextEntry}
editable={!disabled}
multiline={multiline}
numberOfLines={numberOfLines}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
{error && (
<Text style={[styles.error, { color: colors.danger }]}>{error}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { marginBottom: 16 },
label: { fontSize: 13, fontWeight: '500', marginBottom: 6 },
input: {
height: 44,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
fontSize: 15,
},
error: { fontSize: 12, marginTop: 4 },
});
Step 4: Theme Configuration
ทำไมต้องมี Theme System?
ลองนึกถึงแบรนด์ดังๆ อย่าง Grab หรือ LINE — ทั้ง app ใช้สีเขียวสม่ำเสมอทุกปุ่มทุกหน้า ถ้าเรา hardcode #3B82F6 กระจายทั่ว 50 files แล้วต้องเปลี่ยนสี… นั่นคือฝันร้าย Theme System แก้ปัญหานี้ — เปลี่ยนที่เดียว ทั้ง app เปลี่ยนตาม
และที่สำคัญกว่าคือ Dark Mode — ผู้ใช้หลายคนเปิดดู IoT ตอนดึกๆ ตาจะได้ไม่บอด (ᵕ̣̣̣̣̣̣﹏ᵕ̣̣̣̣̣̣)
4.1 Color Palette
// src/theme/colors.ts
export const lightColors = {
// Primary
primary: '#3B82F6',
primaryLight: '#EFF6FF',
secondary: '#8B5CF6',
// Status
success: '#10B981',
successLight: '#ECFDF5',
warning: '#F59E0B',
warningLight: '#FFFBEB',
danger: '#EF4444',
dangerLight: '#FEF2F2',
info: '#06B6D4',
infoLight: '#ECFEFF',
// Background
background: '#F8FAFC',
surface: '#FFFFFF',
inputBackground: '#F1F5F9',
// Text
text: '#0F172A',
textSecondary: '#64748B',
textTertiary: '#94A3B8',
// Border
border: '#E2E8F0',
// Misc
overlay: 'rgba(0,0,0,0.5)',
};
export const darkColors: typeof lightColors = {
primary: '#60A5FA',
primaryLight: '#1E3A5F',
secondary: '#A78BFA',
success: '#34D399',
successLight: '#064E3B',
warning: '#FBBF24',
warningLight: '#451A03',
danger: '#F87171',
dangerLight: '#450A0A',
info: '#22D3EE',
infoLight: '#164E63',
background: '#0F172A',
surface: '#1E293B',
inputBackground: '#334155',
text: '#F1F5F9',
textSecondary: '#94A3B8',
textTertiary: '#64748B',
border: '#334155',
overlay: 'rgba(0,0,0,0.7)',
};
4.2 Theme Hook
เรา useColorScheme() เพื่ออ่านค่า dark/light จากระบบมือถือ — ถ้า user ตั้งมือถือเป็น dark mode, app ก็จะสลับให้อัตโนมัติเลย ไม่ต้องกดเองใน app อีก
// src/theme/index.ts
import { useColorScheme } from '@lynx-js/react';
import { lightColors, darkColors } from './colors';
export { lightColors, darkColors };
export function useTheme() {
const scheme = useColorScheme();
const isDark = scheme === 'dark';
const colors = isDark ? darkColors : lightColors;
return { colors, isDark };
}
Step 5: API Service Layer
ทำไมต้องมี Service Layer?
ลองนึกว่า app คือ “พนักงาน” และ API คือ “ฝ่ายหลังบ้าน” — แทนที่พนักงานทุกคนจะเดินเข้าไปในครัวเองทุกครั้ง เราควรมี “พนักงานเสิร์ฟ” คนกลางที่รู้ว่าต้องคุยกับครัวยังไง นั่นคือ Service Layer
ข้อดีคือ: ถ้า API endpoint เปลี่ยน เราแก้แค่ที่เดียว ไม่ต้องตามแก้ทุกหน้า
5.1 TypeScript Types
Types คือ “สัญญา” ระหว่าง frontend กับ backend — ทั้งสองฝั่งตกลงกันว่า Device object มีหน้าตายังไง TypeScript จะเตือนทันทีถ้าฝั่งไหนผิดสัญญา
// src/types/index.ts
export interface Device {
id: string;
name: string;
type: 'sensor' | 'actuator' | 'gateway';
status: 'online' | 'offline' | 'error';
location: string;
lastSeen: string;
metadata: Record<string, unknown>;
}
export interface SensorReading {
deviceId: string;
temperature?: number;
humidity?: number;
pressure?: number;
battery?: number;
timestamp: string;
}
export interface Alert {
id: string;
deviceId: string;
deviceName: string;
type: 'temperature' | 'humidity' | 'battery' | 'offline' | 'custom';
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
isRead: boolean;
triggeredAt: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;
total?: number;
page?: number;
limit?: number;
}
5.2 API Client
ส่วนที่น่าสนใจที่สุดคือ interceptors สองตัว:
- Request interceptor — ก่อน request ออกไป ดึง token จาก storage แล้วแปะ header ให้อัตโนมัติ (เหมือนพนักงานแสดงบัตรก่อนเข้าออฟฟิศทุกครั้ง)
- Response interceptor — ถ้า API ตอบ 401 (unauthorized) แปลว่า token หมดอายุ ล้าง token ทิ้งแล้ว trigger logout เลย (เหมือน security ที่จะไม่ปล่อยให้คนบัตรหมดอายุเข้าได้)
// src/services/api.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import AsyncStorage from '@lynx-js/async-storage';
import { API_URL } from '../utils/env';
import type { Device, SensorReading, Alert, ApiResponse } from '../types';
class ApiService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// Request interceptor - attach token
this.client.interceptors.request.use(async (config) => {
const token = await AsyncStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor - handle errors
this.client.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401) {
await AsyncStorage.removeItem('auth_token');
// trigger logout / redirect
}
return Promise.reject(error);
}
);
}
// ========== Devices ==========
async getDevices(): Promise<ApiResponse<Device[]>> {
const { data } = await this.client.get('/api/v1/devices');
return data;
}
async getDevice(id: string): Promise<Device> {
const { data } = await this.client.get(`/api/v1/devices/${id}`);
return data.data;
}
async sendCommand(
deviceId: string,
command: string,
payload: Record<string, unknown>
): Promise<void> {
await this.client.post(`/api/v1/devices/${deviceId}/commands`, {
command,
payload,
});
}
// ========== Sensor Data ==========
async getLatestReadings(deviceId: string): Promise<SensorReading> {
const { data } = await this.client.get(
`/api/v1/devices/${deviceId}/readings/latest`
);
return data.data;
}
async getReadingHistory(
deviceId: string,
range: '1h' | '6h' | '24h' | '7d' = '24h'
): Promise<SensorReading[]> {
const { data } = await this.client.get(
`/api/v1/devices/${deviceId}/readings`,
{ params: { range } }
);
return data.data;
}
// ========== Alerts ==========
async getAlerts(params?: {
isRead?: boolean;
severity?: string;
page?: number;
limit?: number;
}): Promise<ApiResponse<Alert[]>> {
const { data } = await this.client.get('/api/v1/alerts', { params });
return data;
}
async markAlertRead(alertId: string): Promise<void> {
await this.client.patch(`/api/v1/alerts/${alertId}/read`);
}
async markAllAlertsRead(): Promise<void> {
await this.client.patch('/api/v1/alerts/read-all');
}
}
export const api = new ApiService();
Step 6: Environment Variables
ทำไมต้องมี .env?
สมมติน้องๆ push code ขึ้น GitHub โดยลืม hardcode http://192.168.1.100:3000 ไว้ใน code — ทุกคนที่ clone repo ไปจะรันไม่ได้เลยเพราะ IP นั้นเป็นของเครื่องน้อง .env แก้ปัญหานี้ด้วยการแยก “ค่าที่เปลี่ยนตามสภาพแวดล้อม” ออกจาก code
6.1 .env.example
ไฟล์นี้ commit เข้า git ได้ เพราะไม่มีค่าจริงๆ เป็นแค่ “แบบฟอร์ม” ให้คนอื่น copy ไปทำ .env ของตัวเอง:
# frontend-mobile/.env.example
# API
EXPO_PUBLIC_API_URL=http://localhost:3000
EXPO_PUBLIC_WS_URL=ws://localhost:3000/ws
# App
EXPO_PUBLIC_APP_NAME=IoT Workshop
EXPO_PUBLIC_APP_VERSION=1.0.0
# Feature Flags
EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS=true
EXPO_PUBLIC_ENABLE_DARK_MODE=true
6.2 Env Helper
แทนที่จะเรียก process.env.EXPO_PUBLIC_API_URL ตรงๆ ทั่ว codebase เราสร้าง helper ไว้ที่เดียว — ถ้าชื่อ env var เปลี่ยน แก้ที่นี่ที่เดียวพอ:
// src/utils/env.ts
export const API_URL =
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
export const WS_URL =
process.env.EXPO_PUBLIC_WS_URL ?? 'ws://localhost:3000/ws';
export const APP_NAME =
process.env.EXPO_PUBLIC_APP_NAME ?? 'IoT Workshop';
export const ENABLE_PUSH =
process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS === 'true';
6.3 lynx.config.ts
Config หลักของ LynxJS — ตั้งค่า app name, bundle ID, และ permissions ที่ต้องขอจาก OS:
// lynx.config.ts
import { defineConfig } from '@lynx-js/core';
export default defineConfig({
appName: process.env.EXPO_PUBLIC_APP_NAME ?? 'IoT Workshop',
bundleIdentifier: 'com.workshop.iot',
version: '1.0.0',
orientation: 'portrait',
plugins: [],
android: {
package: 'com.workshop.iot',
permissions: [
'android.permission.INTERNET',
'android.permission.VIBRATE',
'android.permission.RECEIVE_BOOT_COMPLETED',
],
},
ios: {
bundleIdentifier: 'com.workshop.iot',
infoPlist: {
NSCameraUsageDescription: 'Used for QR code scanning',
},
},
});
Step 7: App Entry Point
ไฟล์นี้เล็กมาก แต่สำคัญสุด — เป็น “ประตูทางเข้า” ของ app ทั้งหมด SafeAreaProvider ทำให้ content ไม่ถูก notch หรือ home bar บนมือถือบัง:
// src/App.tsx
import { SafeAreaProvider } from '@lynx-js/react';
import TabNavigator from './navigation/TabNavigator';
export default function App() {
return (
<SafeAreaProvider>
<TabNavigator />
</SafeAreaProvider>
);
}
Step 8: รัน Dev Server
ถึงเวลาเห็นผลงานแล้ว! รันคำสั่งนี้:
# รัน development server
cd frontend-mobile
npm run dev
# หรือผ่าน Makefile (root)
make mobile-dev
หลังจาก run แล้วจะมี QR code โผล่ขึ้นมา — เปิด LynxJS Viewer App บนมือถือแล้ว scan ได้เลย จะเห็น app รันบนมือถือทันที (ง’̀-‘́)ง
สรุปสิ่งที่เราทำวันนี้
╔══════════════════════════════════════╗
║ ✓ LynxJS project ใน monorepo ║
║ ✓ Tab Navigation 4 แท็บ ║
║ ✓ Base Components + TypeScript ║
║ ✓ Theme System (Dark/Light) ║
║ ✓ API Service Layer + interceptors║
║ ✓ Environment Variables ║
╚══════════════════════════════════════╝
น้องๆ อาจยังไม่เห็น “ของจริง” มากนักในบทนี้ เพราะบทนี้คือการ “วางรากฐาน” — เหมือนการเทพื้น คอนกรีตก่อนสร้างบ้าน มันไม่สวย แต่ถ้าไม่มีมัน ทุกอย่างจะพัง
บทหน้าเราจะเอา components พวกนี้มาประกอบกันเป็น Real-time Dashboard ที่เห็นข้อมูล IoT แบบสดๆ เลย — รอติดตามด้วยนะ!
Navigation:
- Prev: #12 Kapacitor Alerting
- Next: #14 Real-time Dashboard