# BẢN THIẾT KẾ KIẾN TRÚC & PHÁT TRIỂN ỨNG DỤNG GEOSPORT LOG (iOS & Android)Bản tài liệu này hướng dẫn chi tiết cách chuyển đổi phiên bản Web Prototype của GeoSport Log thành một ứng dụng di động Native/Cross-platform hoàn chỉnh sử dụng React Native (Expo SDK), TypeScript, và NativeWind (Tailwind CSS cho Mobile). ## 1. PHÂN TÍCH & LỰA CHỌN CÔNG NGHỆ (TECH STACK) Để mang lại trải nghiệm mượt mà nhất trên cả hai hệ điều hành iOS và Android, dự án được xây dựng dựa trên các công nghệ sau: Framework: React Native với Expo SDK (Hỗ trợ cấu hình tối giản, thư viện cảm biến phần cứng đồng bộ tốt). Ngôn ngữ: TypeScript giúp kiểm soát chặt chẽ các kiểu dữ liệu tọa độ, lịch trình và chỉ số sức khỏe. UI/Styling: NativeWind (Tailwind v3/v4) để tái sử dụng toàn bộ tư duy thiết kế tối giản (Minimalism) và hệ màu tối (Dark Mode) từ bản Web. Bản đồ Native: react-native-maps tích hợp Apple Maps (iOS) và Google Maps (Android) đạt hiệu năng 60fps khi thu phóng và xoay bản đồ. Lưu trữ Offline: expo-sqlite quản lý dữ liệu log hành trình, tọa độ GPS, nhịp tim và đường dẫn tệp đa phương tiện trực tiếp trên thiết bị. ## 2. QUYỀN HỆ THỐNG & CẤU HÌNH PHẦN CỨNG (app.json) Để hệ điều hành cho phép theo dõi GPS khi tắt màn hình và chụp ảnh/quay phim gắn vị trí, chúng ta cần cấu hình quyền truy cập hệ thống chi tiết trong tệp app.json của Expo. ```json { "expo": { "name": "GeoSport Log", "slug": "geosport-log", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", "backgroundColor": "#050811" }, "ios": { "supportsTablet": false, "bundleIdentifier": "com.geosport.log", "infoPlist": { "UIBackgroundModes": ["location"], "NSLocationWhenInUseUsageDescription": "GeoSport Log cần quyền định vị khi mở ứng dụng để vẽ lộ trình chạy bộ/đạp xe thời gian thực của bạn.", "NSLocationAlwaysAndWhenInUseUsageDescription": "GeoSport Log cần quyền định vị chạy ngầm để tiếp tục ghi nhận lộ trình của bạn khi màn hình điện thoại tắt hoặc bỏ vào túi.", "NSCameraUsageDescription": "Cho phép ứng dụng sử dụng Camera để chụp ảnh hoặc quay video gắn mốc GPS trực tiếp trên hành trình di chuyển.", "NSMicrophoneUsageDescription": "Cho phép ứng dụng sử dụng Micro khi quay video hành trình.", "NSPhotoLibraryUsageDescription": "Cho phép ứng dụng truy cập thư viện để đính kèm hình ảnh/video phong cảnh của hành trình tập luyện." } }, "android": { "package": "com.geosport.log", "permissions": [ "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION", "ACCESS_BACKGROUND_LOCATION", "CAMERA", "RECORD_AUDIO", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE", "FOREGROUND_SERVICE" ] }, "plugins": [ [ "expo-location", { "locationAlwaysAndWhenInUsePermission": "Cho phép GeoSport Log theo dõi hành trình của bạn kể cả khi chạy ngầm dưới nền để tránh mất dấu tracklog." } ], [ "expo-camera", { "cameraPermission": "Cho phép truy cập máy ảnh để chụp hình lưu niệm dọc đường đi." } ] ] } } ## 3. CẤU TRÚC THƯ MỤC CHUẨN HOÁ SẢN XUẤT (NO-REFACTOR DIRECTORY) Để tránh hoàn toàn việc phải tái cấu trúc hệ thống khi mở rộng tính năng (thêm màn hình, thêm cảm biến ngoại vi, đồng bộ đám mây), dự án được xây dựng theo kiến trúc phân lớp hướng đối tượng kết hợp với Expo Router v3 (File-based Routing). Hệ thống sử dụng TypeScript Path Aliases (@/*) trỏ thẳng về thư mục /src, giải quyết triệt để lỗi "đường dẫn tương đối tương đối" (../../../../). geosport-log-mobile/ ├── assets/ # Tài nguyên tĩnh (Fonts, Icons, Splash Screens) ├── src/ │ ├── app/ # PHÂN LỚP ĐƯỜNG DẪN (Expo Router 3+ Entry Points) │ │ ├── (auth)/ # Nhóm phân quyền tài khoản (Đăng nhập, đăng ký) │ │ │ ├── login.tsx │ │ │ └── _layout.tsx │ │ ├── (tabs)/ # Nhóm giao diện chính (Bottom Navigation) │ │ │ ├── index.tsx # Màn hình ghi HUD (Bản đồ GPS, Kcal, Nhịp tim) │ │ │ ├── history.tsx # Nhật ký Lịch (Workout Calendar) │ │ │ ├── analytics.tsx # Dashboard Biểu đồ phân tích (Chart.js) │ │ │ └── _layout.tsx # Custom bottom tab bar styled │ │ ├── replay/ # Nhóm trình chiếu Cinematic │ │ │ └── [id].tsx # Màn hình phát Cinematic theo ID hành trình (Gồm overlay Video/Ảnh mới) │ │ ├── _layout.tsx # Root Layout (Khởi tạo Database, Zustand State Providers) │ │ └── modal.tsx # Camera overlay sheet / Chọn ảnh mẫu │ │ │ ├── components/ # PHÂN LỚP GIAO DIỆN (Tái sử dụng) │ │ ├── common/ # UI nền tảng (Buttons, Custom Modals, Toasts, Loaders) │ │ ├── map/ # UI bản đồ (Custom MapView, Polyline, Custom Pins) │ │ ├── replay/ # UI chiếu phim (Cinematic HUD, Media Player Overlay) │ │ └── analytics/ # UI biểu đồ (Chart Wrappers, Stat Cards) │ │ │ ├── constants/ # PHÂN LỚP HẰNG SỐ & ĐỊNH CẤU HÌNH │ │ ├── Colors.ts # Hệ thống mã màu tối tối giản (Dark Mode Palette) │ │ └── Config.ts # Cấu hình MET values, chu kỳ lấy mẫu, Bluetooth UUIDs │ │ │ ├── database/ # PHÂN LỚP PERSISTENCE (SQLite Engine) │ │ ├── sqlite.ts # Trình khởi tạo SQLite, quản lý phiên bản (Migration) │ │ └── repositories/ # Mô hình Repository cô lập câu lệnh SQL với giao diện UI │ │ ├── workoutRepository.ts │ │ └── mediaRepository.ts │ │ │ ├── hooks/ # PHÂN LỚP HOOKS TỰ CHẾ (Side Effects & State) │ │ ├── useBackgroundGPS.ts # Hook kích hoạt & dừng tracking GPS chạy ngầm │ │ ├── useBluetoothBLE.ts # Hook quét & ghép nối thiết bị đo nhịp tim (ESP32/Cảm biến) │ │ ├── useWeather.ts # Hook tự động tải dữ liệu thời tiết dựa theo GPS │ │ └── useCalendar.ts # Hook tính toán dữ liệu hiển thị lịch tuần/tháng │ │ │ ├── services/ # PHÂN LỚP KẾT NỐI NGOẠI VI (API & Hardware Clients) │ │ ├── weatherService.ts # Open-Meteo REST API wrapper │ │ └── bleService.ts # SDK Bluetooth Low Energy (quản lý kết nối ESP32) │ │ │ ├── store/ # PHÂN LỚP STATE TOÀN CỤC (Zustand Engines) │ │ └── useWorkoutStore.ts # Quản lý phiên ghi, tính Kcal thời gian thực │ │ │ ├── types/ # PHÂN LỚP KIỂU TS (Type Safety) │ │ ├── database.types.ts # Kiểu dữ liệu tương thích Schema SQLite │ │ └── workout.types.ts # Kiểu dữ liệu Trackpoint, Media, Workout │ │ │ └── utils/ # PHÂN LỚP HÀM TIỆN ÍCH PURE LOGIC │ ├── calculations.ts # Công thức Haversine, công thức MET, tính Kcal │ ├── date.ts # Định dạng chuỗi ngày tháng, nhãn thời gian │ └── fileSystem.ts # Expo FileSystem quản lý ghi, nén tệp ảnh/video cục bộ │ ├── tailwind.config.js # Cấu hình NativeWind (Tailwind CSS cho mobile) ├── tsconfig.json # Cấu hình TypeScript Paths (`@/*` mapping) └── package.json # Danh sách thư viện liên kết ## 4. CHUẨN HOÁ CẤU HÌNH TSCONFIG & PATH ALIASES (tsconfig.json) Để liên kết các thành phần kiến trúc không bị lỗi đường dẫn khi di chuyển các file, cấu hình tsconfig.json bắt buộc phải map thư mục gốc @/ trỏ thẳng vào /src. { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } ## 5. THIẾT KẾ CƠ SỞ DỮ LIỆU SQLITE CỤC BỘ (sqlite.ts) Ứng dụng lưu trữ dữ liệu theo mô hình Offline-First. Khi người dùng đi vào vùng núi hoặc mất sóng mạng 4G, mọi tọa độ GPS, ảnh chụp và dữ liệu nhịp tim vẫn được lưu trữ bình thường vào SQLite cục bộ. // src/database/sqlite.ts import * as SQLite from 'expo-sqlite'; let db: SQLite.SQLiteDatabase | null = null; export const getDBConnection = async (): Promise => { if (db) return db; db = await SQLite.openDatabaseAsync('geosport_log.db'); // Khởi tạo các bảng dữ liệu nếu chưa tồn tại await db.execAsync(` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS workouts ( id TEXT PRIMARY KEY NOT NULL, title TEXT NOT NULL, type TEXT NOT NULL, /* RUNNING, CYCLING, WALKING */ date TEXT NOT NULL, /* YYYY-MM-DD */ start_time INTEGER NOT NULL, /* Timestamp */ duration INTEGER NOT NULL, /* Seconds */ distance REAL NOT NULL, /* Km */ avg_speed REAL NOT NULL, avg_hr INTEGER, calories INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS trackpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id TEXT NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, speed REAL NOT NULL, heart_rate INTEGER, timestamp INTEGER NOT NULL, FOREIGN KEY (workout_id) REFERENCES workouts (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS media_attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id TEXT NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, type TEXT NOT NULL, /* IMAGE, VIDEO */ file_uri TEXT NOT NULL, /* Local File System URI */ caption TEXT, timestamp INTEGER NOT NULL, FOREIGN KEY (workout_id) REFERENCES workouts (id) ON DELETE CASCADE ); `); return db; }; ## 6. TEMPLATE QUẢN LÝ TRẠNG THÁI TOÀN CỤC (useWorkoutStore.ts) Sử dụng Zustand để quản lý trạng thái tập luyện thời gian thực, tích hợp bộ xử lý tính toán nhịp tim và lượng Kcal tiêu thụ theo thời gian thực. // src/store/useWorkoutStore.ts import { create } from 'zustand'; import { getDBConnection } from '../database/sqlite'; import { Trackpoint, MediaAttachment } from '../types/workout.types'; import { calculateDistance, calculateMetKcal } from '../utils/calculations'; interface WorkoutState { isTracking: boolean; workoutType: 'RUNNING' | 'CYCLING' | 'WALKING'; currentWorkoutId: string | null; routePoints: Trackpoint[]; mediaAttachments: MediaAttachment[]; distance: number; // km duration: number; // seconds currentSpeed: number; // km/h currentHeartRate: number; // bpm caloriesBurned: number; // kcal weightKg: number; // mặc định dùng 70kg để tính MET startWorkout: (type: 'RUNNING' | 'CYCLING' | 'WALKING') => void; stopWorkout: () => Promise; addTrackpoint: (point: Trackpoint) => void; addMediaAttachment: (media: MediaAttachment) => void; updateTimer: () => void; } export const useWorkoutStore = create((set, get) => ({ isTracking: false, workoutType: 'RUNNING', currentWorkoutId: null, routePoints: [], mediaAttachments: [], distance: 0, duration: 0, currentSpeed: 0, currentHeartRate: 75, caloriesBurned: 0, weightKg: 70, startWorkout: (type) => { const id = `WK_${Date.now()}`; set({ isTracking: true, workoutType: type, currentWorkoutId: id, routePoints: [], mediaAttachments: [], distance: 0, duration: 0, currentSpeed: 0, currentHeartRate: 75, caloriesBurned: 0, }); }, addTrackpoint: (point) => { const { routePoints, distance, weightKg, workoutType } = get(); let newDistance = distance; if (routePoints.length > 0) { const lastPt = routePoints[routePoints.length - 1]; const d = calculateDistance(lastPt.lat, lastPt.lng, point.lat, point.lng); newDistance += d; } const newlyBurnedKcal = calculateMetKcal(workoutType, weightKg, point.hr, 10); set((state) => ({ routePoints: [...state.routePoints, point], distance: newDistance, currentSpeed: point.speed, currentHeartRate: point.hr, caloriesBurned: state.caloriesBurned + newlyBurnedKcal, })); }, addMediaAttachment: (media) => { set((state) => ({ mediaAttachments: [...state.mediaAttachments, media], })); }, updateTimer: () => { set((state) => ({ duration: state.duration + 1 })); }, stopWorkout: async () => { const { currentWorkoutId, workoutType, distance, duration, routePoints, mediaAttachments, caloriesBurned } = get(); if (!currentWorkoutId) return null; set({ isTracking: false }); try { const db = await getDBConnection(); const avgSpeed = duration > 0 ? (distance / (duration / 3600)) : 0; const totalHR = routePoints.reduce((acc, p) => acc + p.hr, 0); const avgHR = routePoints.length > 0 ? Math.round(totalHR / routePoints.length) : 75; const todayStr = new Date().toISOString().split('T')[0]; // 1. Lưu vào bảng Workouts await db.runAsync( `INSERT INTO workouts (id, title, type, date, start_time, duration, distance, avg_speed, avg_hr, calories) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [currentWorkoutId, `Hành trình ${workoutType}`, workoutType, todayStr, Date.now(), duration, distance, avgSpeed, avgHR, Math.round(caloriesBurned)] ); // 2. Lưu các Trackpoints for (const p of routePoints) { await db.runAsync( `INSERT INTO trackpoints (workout_id, latitude, longitude, speed, heart_rate, timestamp) VALUES (?, ?, ?, ?, ?, ?)`, [currentWorkoutId, p.lat, p.lng, p.speed, p.hr, p.timestamp] ); } // 3. Lưu các Media đính kèm for (const m of mediaAttachments) { await db.runAsync( `INSERT INTO media_attachments (workout_id, latitude, longitude, type, file_uri, caption, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)`, [currentWorkoutId, m.lat, m.lng, m.type, m.uri, m.caption, Date.now()] ); } return currentWorkoutId; } catch (error) { console.error("Lỗi khi lưu trữ SQLite:", error); return null; } } })); ## 7. THIẾT KẾ CÁC MÀN HÌNH CHÍNH (MOBILE SCREEN TEMPLATES) ### A. Màn hình HUD ghi nhật ký (RecordScreen.tsx) Giao diện tối giản (Minimalism) tối ưu không gian hiển thị cho bản đồ và các chỉ số thể thao quan trọng. Tất cả các tính năng cài đặt, lịch tập, thống kê biểu đồ đều được gom gọn vào nút Hamburger trượt từ bên phải. // src/app/(tabs)/index.tsx import React, { useEffect, useRef, useState } from 'react'; import { View, Text, TouchableOpacity, Alert, SafeAreaView } from 'react-native'; import MapView, { Polyline, PROVIDER_GOOGLE } from 'react-native-maps'; import { useWorkoutStore } from '@/store/useWorkoutStore'; import * as Location from 'expo-location'; import * as TaskManager from 'expo-task-manager'; import { Ionicons } from '@expo/vector-icons'; import { fetchCurrentWeather } from '@/services/weatherService'; const LOCATION_TASK_NAME = 'background-location-task'; export default function RecordScreen({ navigation }: any) { const { isTracking, distance, duration, currentSpeed, currentHeartRate, caloriesBurned, startWorkout, stopWorkout, routePoints } = useWorkoutStore(); const [weather, setWeather] = useState({ temp: '--', icon: 'cloud-outline' }); const mapRef = useRef(null); // 1. Lấy dữ liệu thời tiết thực tế từ Weather Service useEffect(() => { const getWeather = async () => { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') return; const loc = await Location.getCurrentPositionAsync({}); const wData = await fetchCurrentWeather(loc.coords.latitude, loc.coords.longitude); setWeather(wData); }; getWeather(); }, []); // 2. Chạy đồng hồ đếm thời gian tập useEffect(() => { let interval: any = null; if (isTracking) { interval = setInterval(() => { useWorkoutStore.getState().updateTimer(); }, 1000); } else { clearInterval(interval); } return () => clearInterval(interval); }, [isTracking]); // 3. Khởi tạo tác vụ ghi GPS chạy ngầm const handleToggleTracking = async () => { if (!isTracking) { const { status: foreground } = await Location.requestForegroundPermissionsAsync(); const { status: background } = await Location.requestBackgroundPermissionsAsync(); if (foreground !== 'granted' || background !== 'granted') { Alert.alert('Quyền Định Vị', 'Bạn cần cấp quyền định vị Always (Luôn cho phép) để ứng dụng chạy ngầm không bị mất log.'); return; } startWorkout('RUNNING'); await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, { accuracy: Location.Accuracy.BestForNavigation, timeInterval: 5000, distanceInterval: 5, foregroundService: { notificationTitle: "GeoSport Log đang hoạt động", notificationBody: "Đang ghi lại lộ trình di chuyển của bạn...", notificationColor: "#10b981" } }); } else { await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); const savedId = await stopWorkout(); if (savedId) { Alert.alert('Thành Công', 'Đã lưu lại hành trình của bạn vào Nhật ký cục bộ.'); } } }; const formatTime = (secs: number) => { const m = Math.floor(secs / 60).toString().padStart(2, '0'); const s = (secs % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; return ( {routePoints.length > 0 && ( ({ latitude: p.lat, longitude: p.lng }))} strokeColor="#10b981" strokeWidth={5} /> )} {/* HEADER OVERLAY HUD */} {weather.temp} GEOSPORT LOG navigation.openDrawer()} className="w-10 h-10 rounded-2xl bg-slate-950/90 border border-slate-800 flex items-center justify-center text-white" > {/* LOWER STATUS AND CONTROL HUD */} Cự ly {distance.toFixed(2)} km Thời gian {formatTime(duration)} Tốc độ {currentSpeed.toFixed(1)} km/h {currentHeartRate} BPM {Math.round(caloriesBurned)} Kcal {isTracking ? 'KẾT THÚC & LƯU CHUYẾN ĐI' : 'BẮT ĐẦU GHI HÀNH TRÌNH'} ); } TaskManager.defineTask(LOCATION_TASK_NAME, ({ data, error }: any) => { if (error) return; if (data) { const { locations } = data; const location = locations[0]; const speedKmh = location.coords.speed ? location.coords.speed * 3.6 : 10; const simulatedHR = Math.round(110 + (speedKmh * 3.5) + (Math.random() * 8 - 4)); useWorkoutStore.getState().addTrackpoint({ lat: location.coords.latitude, lng: location.coords.longitude, speed: parseFloat(speedKmh.toFixed(1)), hr: Math.min(Math.max(simulatedHR, 70), 185), timestamp: location.timestamp }); } }); const darkMapStyle = [ { "elementType": "geometry", "stylers": [{ "color": "#050811" }] }, { "elementType": "labels.text.fill", "stylers": [{ "color": "#747985" }] }, { "elementType": "labels.text.stroke", "stylers": [{ "color": "#050811" }] }, { "featureType": "road", "elementType": "geometry", "stylers": [{ "color": "#0d1527" }] }, { "featureType": "water", "elementType": "geometry", "stylers": [{ "color": "#02040a" }] } ]; ### B. Màn hình Trình phát phim Cinematic (ReplayScreen.tsx) Bản vẽ mô phỏng góc nhìn POV di chuyển dọc theo lộ trình. Khi đi qua các tọa độ có gắn thẻ ảnh/video đa phương tiện từ SQLite, Camera sẽ tự động dừng lại, mở popup trình phát nội dung, sau đó tự động tiếp tục cho đến vạch đích. #### Nâng cấp mới cấu trúc lớp phủ HUD di động: Khi xem Video: Xuất hiện một SVG vẽ lộ trình thu nhỏ (màu đỏ rực rỡ) ở góc trên bên trái, kèm chấm xanh neon biểu diễn định vị GPS di động biến động theo tiến độ của video clip. Khi xem Ảnh: Xuất hiện lớp phủ HUD bán trong suốt ở đáy ảnh, toàn bộ chữ (Địa danh, Tọa độ GPS, Thời gian chụp) mang màu vàng nắng phát sáng lung linh (textShadowColor: '#facc15') tăng độ hiển thị thẩm mỹ tối đa. // src/app/replay/[id].tsx import React, { useEffect, useState, useRef } from 'react'; import { View, Text, Image, TouchableOpacity, SafeAreaView, Dimensions, StyleSheet } from 'react-native'; import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av'; import MapView, { Marker, Polyline } from 'react-native-maps'; import Svg, { Polyline as SvgPolyline, Circle as SvgCircle } from 'react-native-svg'; import { Trackpoint, MediaAttachment } from '@/types/workout.types'; import { useWorkoutStore } from '@/store/useWorkoutStore'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams } from 'expo-router'; export default function ReplayScreen() { const { id } = useLocalSearchParams(); const [points, setPoints] = useState([]); const [media, setMedia] = useState([]); const [currentIdx, setCurrentIdx] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [activeMedia, setActiveMedia] = useState(null); // Video playback progress (0 to 1) to animate the mini tracklog indicator const [videoProgress, setVideoProgress] = useState(0); const [mediaCountdown, setMediaCountdown] = useState(6); const mapRef = useRef(null); const videoRef = useRef