ตั้งค่า LynxJS Mobile App สำหรับ IoT

ตั้งค่า LynxJS Mobile App สำหรับ IoT

Showkhun · Workshop ·

ตั้งค่า LynxJS Mobile App สำหรับ IoT

Branch: workshop/dev-10-lynxjs-setup Phase: 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 ง่ายกว่า

Mermaid Diagram

ภาพรวมคือ: 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: