1088 lines
47 KiB
Markdown
1088 lines
47 KiB
Markdown
# 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<SQLite.SQLiteDatabase> => {
|
|
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<string | null>;
|
|
addTrackpoint: (point: Trackpoint) => void;
|
|
addMediaAttachment: (media: MediaAttachment) => void;
|
|
updateTimer: () => void;
|
|
}
|
|
|
|
export const useWorkoutStore = create<WorkoutState>((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<MapView | null>(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 (
|
|
<View className="flex-1 bg-[#050811]">
|
|
<MapView
|
|
ref={mapRef}
|
|
provider={PROVIDER_GOOGLE}
|
|
customMapStyle={darkMapStyle}
|
|
className="flex-1"
|
|
initialRegion={{
|
|
latitude: 16.0684,
|
|
longitude: 108.2460,
|
|
latitudeDelta: 0.01,
|
|
longitudeDelta: 0.01,
|
|
}}
|
|
showsUserLocation
|
|
followsUserLocation
|
|
>
|
|
{routePoints.length > 0 && (
|
|
<Polyline
|
|
coordinates={routePoints.map(p => ({ latitude: p.lat, longitude: p.lng }))}
|
|
strokeColor="#10b981"
|
|
strokeWidth={5}
|
|
/>
|
|
)}
|
|
</MapView>
|
|
|
|
{/* HEADER OVERLAY HUD */}
|
|
<SafeAreaView className="absolute top-4 left-4 right-4 flex-row justify-between items-center z-10">
|
|
<View className="bg-slate-950/90 border border-slate-800/80 rounded-2xl px-3 py-2 flex-row items-center gap-2">
|
|
<Ionicons name={weather.icon as any} size={18} color="#f59e0b" />
|
|
<Text className="text-xs font-bold text-white">{weather.temp}</Text>
|
|
</View>
|
|
|
|
<View className="bg-slate-950/90 border border-slate-800/80 px-4 py-2 rounded-2xl">
|
|
<Text className="font-extrabold text-xs text-white uppercase">
|
|
GEOSPORT <Text className="text-emerald-400">LOG</Text>
|
|
</Text>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => navigation.openDrawer()}
|
|
className="w-10 h-10 rounded-2xl bg-slate-950/90 border border-slate-800 flex items-center justify-center text-white"
|
|
>
|
|
<Ionicons name="menu-outline" size={20} color="white" />
|
|
</TouchableOpacity>
|
|
</SafeAreaView>
|
|
|
|
{/* LOWER STATUS AND CONTROL HUD */}
|
|
<View className="absolute bottom-6 left-4 right-4 z-10 gap-3">
|
|
<View className="bg-slate-950/90 border border-slate-800/80 rounded-3xl p-4 shadow-2xl">
|
|
<View className="flex-row justify-between mb-4">
|
|
<View className="align-center flex-1">
|
|
<Text className="text-[9px] font-extrabold text-slate-500 uppercase">Cự ly</Text>
|
|
<Text className="text-xl font-black text-white">{distance.toFixed(2)} <Text class="text-xs font-normal">km</Text></Text>
|
|
</View>
|
|
<View className="align-center flex-1 border-l border-slate-800/60 pl-3">
|
|
<Text className="text-[9px] font-extrabold text-slate-500 uppercase">Thời gian</Text>
|
|
<Text className="text-xl font-black text-white">{formatTime(duration)}</Text>
|
|
</View>
|
|
<View className="align-center flex-1 border-l border-slate-800/60 pl-3">
|
|
<Text className="text-[9px] font-extrabold text-slate-500 uppercase">Tốc độ</Text>
|
|
<Text className="text-xl font-black text-emerald-400">{currentSpeed.toFixed(1)} <Text class="text-xs font-normal">km/h</Text></Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-row justify-between pt-3 border-t border-slate-800/40">
|
|
<View className="flex-row items-center gap-1.5">
|
|
<Ionicons name="heart-sharp" size={16} color="#ef4444" className="animate-pulse" />
|
|
<Text className="text-xs font-bold text-slate-300">{currentHeartRate} BPM</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-1.5">
|
|
<Ionicons name="flame-sharp" size={16} color="#f97316" />
|
|
<Text className="text-xs font-bold text-slate-300">{Math.round(caloriesBurned)} Kcal</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
onPress={handleToggleTracking}
|
|
className={`w-full py-4 rounded-2xl flex-row items-center justify-center gap-2 shadow-lg ${
|
|
isTracking ? 'bg-rose-600' : 'bg-emerald-500'
|
|
}`}
|
|
>
|
|
<Ionicons name={isTracking ? 'stop-sharp' : 'play-sharp'} size={18} color={isTracking ? 'white' : '#090d16'} />
|
|
<Text className={`font-extrabold text-xs tracking-widest ${isTracking ? 'text-white' : 'text-slate-950'}`}>
|
|
{isTracking ? 'KẾT THÚC & LƯU CHUYẾN ĐI' : 'BẮT ĐẦU GHI HÀNH TRÌNH'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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<Trackpoint[]>([]);
|
|
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
|
const [currentIdx, setCurrentIdx] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [activeMedia, setActiveMedia] = useState<MediaAttachment | null>(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<MapView | null>(null);
|
|
const videoRef = useRef<Video | null>(null);
|
|
const timerRef = useRef<any>(null);
|
|
|
|
useEffect(() => {
|
|
// Tải dữ liệu mẫu từ workout đang ghi nhận
|
|
const activeRoute = useWorkoutStore.getState().routePoints;
|
|
const activeMedia = useWorkoutStore.getState().mediaAttachments;
|
|
setPoints(activeRoute);
|
|
setMedia(activeMedia);
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (isPlaying && !activeMedia) {
|
|
timerRef.current = setInterval(() => {
|
|
setCurrentIdx((prev) => {
|
|
const next = prev + 1;
|
|
if (next >= points.length) {
|
|
setIsPlaying(false);
|
|
clearInterval(timerRef.current);
|
|
return prev;
|
|
}
|
|
|
|
const curPt = points[next];
|
|
mapRef.current?.animateToRegion({
|
|
latitude: curPt.lat,
|
|
longitude: curPt.lng,
|
|
latitudeDelta: 0.005,
|
|
longitudeDelta: 0.005
|
|
}, 300);
|
|
|
|
const attachedMedia = media.find(m => Math.abs(m.lat - curPt.lat) < 0.0001);
|
|
if (attachedMedia) {
|
|
triggerMediaReplay(attachedMedia);
|
|
}
|
|
|
|
return next;
|
|
});
|
|
}, 300);
|
|
} else {
|
|
clearInterval(timerRef.current);
|
|
}
|
|
return () => clearInterval(timerRef.current);
|
|
}, [isPlaying, activeMedia, points]);
|
|
|
|
const triggerMediaReplay = (item: MediaAttachment) => {
|
|
setActiveMedia(item);
|
|
setIsPlaying(false);
|
|
setVideoProgress(0);
|
|
|
|
if (item.type === 'IMAGE') {
|
|
setMediaCountdown(6);
|
|
const counter = setInterval(() => {
|
|
setMediaCountdown((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(counter);
|
|
closeMediaShow();
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
const closeMediaShow = () => {
|
|
setActiveMedia(null);
|
|
setIsPlaying(true);
|
|
};
|
|
|
|
const handleVideoPlaybackUpdate = (status: AVPlaybackStatus) => {
|
|
if (!status.isLoaded) return;
|
|
if (status.durationMillis && status.positionMillis) {
|
|
setVideoProgress(status.positionMillis / status.durationMillis);
|
|
}
|
|
if (status.didJustFinish) {
|
|
closeMediaShow();
|
|
}
|
|
};
|
|
|
|
// Helper chuyển đổi hệ tọa độ điểm thực sang khung vẽ phẳng SVG của Mini Tracklog
|
|
const getSvgCoordinates = () => {
|
|
if (points.length === 0) return { pointsStr: '', activePt: { x: 40, y: 40 } };
|
|
|
|
const lats = points.map(p => p.lat);
|
|
const lngs = points.map(p => p.lng);
|
|
const minLat = Math.min(...lats);
|
|
const maxLat = Math.max(...lats);
|
|
const minLng = Math.min(...lngs);
|
|
const maxLng = Math.max(...lngs);
|
|
|
|
const latRange = maxLat - minLat || 0.0001;
|
|
const lngRange = maxLng - minLng || 0.0001;
|
|
|
|
const width = 80;
|
|
const height = 80;
|
|
const padding = 8;
|
|
|
|
const scaleX = (width - padding * 2) / lngRange;
|
|
const scaleY = (height - padding * 2) / latRange;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
|
|
const offsetX = (width - lngRange * scale) / 2;
|
|
const offsetY = (height - latRange * scale) / 2;
|
|
|
|
const mappedStr = points.map(p => {
|
|
const x = offsetX + (p.lng - minLng) * scale;
|
|
const y = height - (offsetY + (p.lat - minLat) * scale);
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
}).join(' ');
|
|
|
|
// Tìm tọa độ chấm xanh động dựa trên tiến độ video
|
|
const startWindowIdx = Math.max(0, currentIdx - 4);
|
|
const endWindowIdx = Math.min(points.length - 1, currentIdx + 4);
|
|
const localRange = endWindowIdx - startWindowIdx;
|
|
const targetFloatIdx = startWindowIdx + (localRange * videoProgress);
|
|
const baseIdx = Math.floor(targetFloatIdx);
|
|
const fraction = targetFloatIdx - baseIdx;
|
|
|
|
let activeLat = points[currentIdx]?.lat || 16.0684;
|
|
let activeLng = points[currentIdx]?.lng || 108.2460;
|
|
|
|
if (baseIdx < points.length - 1) {
|
|
const pA = points[baseIdx];
|
|
const pB = points[baseIdx + 1];
|
|
activeLat = pA.lat + (pB.lat - pA.lat) * fraction;
|
|
activeLng = pA.lng + (pB.lng - pA.lng) * fraction;
|
|
}
|
|
|
|
const activeX = offsetX + (activeLng - minLng) * scale;
|
|
const activeY = height - (offsetY + (activeLat - minLat) * scale);
|
|
|
|
return { pointsStr: mappedStr, activePt: { x: activeX, y: activeY } };
|
|
};
|
|
|
|
const { pointsStr, activePt } = getSvgCoordinates();
|
|
|
|
return (
|
|
<View className="flex-1 bg-[#050811]">
|
|
<MapView
|
|
ref={mapRef}
|
|
className="flex-1"
|
|
initialRegion={{
|
|
latitude: points[0]?.lat || 16.0684,
|
|
longitude: points[0]?.lng || 108.2460,
|
|
latitudeDelta: 0.01,
|
|
longitudeDelta: 0.01,
|
|
}}
|
|
>
|
|
{points.length > 0 && (
|
|
<Polyline coordinates={points.map(p => ({ latitude: p.lat, longitude: p.lng }))} strokeColor="#4f46e5" strokeWidth={6} />
|
|
)}
|
|
|
|
{points[currentIdx] && (
|
|
<Marker coordinate={{ latitude: points[currentIdx].lat, longitude: points[currentIdx].lng }}>
|
|
<View className="w-8 h-8 rounded-full bg-indigo-500 border-2 border-white items-center justify-center shadow-lg">
|
|
<Ionicons name="bicycle-outline" size={16} color="white" />
|
|
</View>
|
|
</Marker>
|
|
)}
|
|
</MapView>
|
|
|
|
{/* REPLAY CONTROLLER HUD */}
|
|
<SafeAreaView className="absolute top-20 left-4 right-4 z-10 bg-slate-950/95 border border-slate-800/80 rounded-2xl p-4">
|
|
<View className="flex-row justify-between items-center mb-3">
|
|
<Text className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest">Hành trình đang tái hiện</Text>
|
|
<TouchableOpacity onPress={() => setIsPlaying(!isPlaying)} className="bg-indigo-600 px-4 py-2 rounded-xl">
|
|
<Text className="text-[11px] font-black text-white">{isPlaying ? 'TẠM DỪNG' : 'TIẾP TỤC'}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View className="w-full bg-slate-800 h-1 rounded-full overflow-hidden">
|
|
<View style={{ width: `${(currentIdx / (points.length || 1)) * 100}%` }} className="bg-indigo-500 h-full" />
|
|
</View>
|
|
</SafeAreaView>
|
|
|
|
{/* FULLSCREEN POPUP HIỂN THỊ ĐA PHƯƠNG TIỆN CINEMATIC */}
|
|
{activeMedia && (
|
|
<View className="absolute inset-0 bg-slate-950/98 z-50 justify-between p-6">
|
|
<View className="flex-row justify-between items-center">
|
|
<View>
|
|
<Text className="text-[9px] text-amber-400 font-extrabold uppercase">Địa danh dừng chân</Text>
|
|
<Text className="text-sm font-bold text-white mt-1">{activeMedia.caption}</Text>
|
|
</View>
|
|
<TouchableOpacity onPress={closeMediaShow} className="bg-slate-900 border border-slate-800 px-4 py-2 rounded-xl">
|
|
<Text className="text-xs text-white font-bold">Bỏ qua ({mediaCountdown}s)</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* CHẤT LIỆU MEDIA HIỂN THỊ CHUYÊN SÂU */}
|
|
<View className="flex-1 items-center justify-center my-4 relative">
|
|
{activeMedia.type === 'IMAGE' ? (
|
|
<View className="w-full relative rounded-2xl overflow-hidden border border-slate-800 bg-slate-900">
|
|
<Image source={{ uri: activeMedia.uri }} className="w-full h-[50vh]" resizeMode="contain" />
|
|
|
|
{/* LỚP PHỦ ẢNH HUD CHỮ VÀNG PHÁT SÁNG (GLOWING YELLOW TEXT OVERLAY) */}
|
|
<View className="absolute bottom-4 left-4 right-4 bg-slate-950/90 border border-slate-800/80 p-3.5 rounded-xl shadow-2xl">
|
|
<Text style={styles.glowTextYellow} className="text-sm font-extrabold tracking-wide uppercase">
|
|
<Ionicons name="location" size={13} color="#facc15" /> {activeMedia.caption || "Mốc kỷ niệm"}
|
|
</Text>
|
|
<View className="flex-row justify-between items-center mt-2 pt-2 border-t border-yellow-500/15">
|
|
<Text style={styles.glowTextYellowSub} className="text-[9px] font-mono">
|
|
GPS: {activeMedia.lat.toFixed(5)}, {activeMedia.lng.toFixed(5)}
|
|
</Text>
|
|
<Text style={styles.glowTextYellowSub} className="text-[9px]">
|
|
{new Date().toLocaleString('vi-VN')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<View className="w-full relative rounded-2xl overflow-hidden border border-slate-800 bg-slate-900">
|
|
<Video
|
|
ref={videoRef}
|
|
source={{ uri: activeMedia.uri }}
|
|
resizeMode={ResizeMode.CONTAIN}
|
|
shouldPlay
|
|
useNativeControls
|
|
onPlaybackStatusUpdate={handleVideoPlaybackUpdate}
|
|
className="w-full h-[50vh]"
|
|
/>
|
|
|
|
{/* LỚP PHỦ LỘ TRÌNH THU NHỎ TRÊN VIDEO (RED PATH & PULSING GREEN TRACKLOG DOT) */}
|
|
<View className="absolute top-4 left-4 w-20 h-20 bg-slate-950/90 border border-slate-800/80 rounded-xl p-1 shadow-2xl items-center justify-center">
|
|
<Svg width="100%" height="100%" viewBox="0 0 80 80">
|
|
{/* Vẽ đường đi toàn lộ trình bằng màu đỏ rực */}
|
|
<SvgPolyline
|
|
points={pointsStr}
|
|
fill="none"
|
|
stroke="#ef4444"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
{/* Vòng tròn neon xanh lá cây nhấp nháy */}
|
|
<SvgCircle
|
|
cx={activePt.x}
|
|
cy={activePt.y}
|
|
r="7"
|
|
fill="rgba(16, 185, 129, 0.4)"
|
|
/>
|
|
{/* Chấm định vị GPS trung tâm */}
|
|
<SvgCircle
|
|
cx={activePt.x}
|
|
cy={activePt.y}
|
|
r="3.5"
|
|
fill="#10b981"
|
|
stroke="#ffffff"
|
|
strokeWidth="1"
|
|
/>
|
|
</Svg>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
glowTextYellow: {
|
|
color: '#facc15',
|
|
fontWeight: '800',
|
|
textShadowColor: 'rgba(250, 204, 21, 0.75)',
|
|
textShadowOffset: { width: 0, height: 0 },
|
|
textShadowRadius: 8,
|
|
},
|
|
glowTextYellowSub: {
|
|
color: '#fef08a',
|
|
fontWeight: '600',
|
|
textShadowColor: 'rgba(250, 204, 21, 0.4)',
|
|
textShadowOffset: { width: 0, height: 0 },
|
|
textShadowRadius: 3,
|
|
}
|
|
});
|
|
### C. Màn hình Biểu đồ phân tích sức khỏe (AnalyticsScreen.tsx)
|
|
|
|
Hiển thị đồ thị tích lũy cự ly, biểu đồ phân bổ các môn thể thao và đồ thị tương quan đa trục giữa Nhịp tim (BPM) & Vận tốc di chuyển theo thời gian bằng cách sử dụng Wagmi Charts hoặc Victory Native.
|
|
|
|
// src/app/(tabs)/analytics.tsx
|
|
import React, { useEffect, useState } from 'react';
|
|
import { ScrollView, View, Text, Dimensions, SafeAreaView } from 'react-native';
|
|
import { getDBConnection } from '@/database/sqlite';
|
|
import { LineChart, PieChart } from 'react-native-chart-kit';
|
|
|
|
export default function AnalyticsScreen() {
|
|
const [totalKm, setTotalKm] = useState(0);
|
|
const [totalKcal, setTotalKcal] = useState(0);
|
|
const [sportDistribution, setSportDistribution] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
const loadStats = async () => {
|
|
const db = await getDBConnection();
|
|
const resTotal: any = await db.getFirstAsync('SELECT SUM(distance) as dist, SUM(calories) as kcal FROM workouts');
|
|
setTotalKm(resTotal?.dist || 0);
|
|
setTotalKcal(resTotal?.kcal || 0);
|
|
|
|
const distData: any[] = await db.getAllAsync(
|
|
'SELECT type, COUNT(id) as count FROM workouts GROUP BY type'
|
|
);
|
|
const chartColors = ['#10b981', '#4f46e5', '#f59e0b'];
|
|
const formattedPie = distData.map((d, index) => ({
|
|
name: d.type,
|
|
population: d.count,
|
|
color: chartColors[index % chartColors.length],
|
|
legendFontColor: '#94a3b8',
|
|
legendFontSize: 11
|
|
}));
|
|
setSportDistribution(formattedPie);
|
|
};
|
|
loadStats();
|
|
}, []);
|
|
|
|
const screenWidth = Dimensions.get('window').width;
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-[#050811]">
|
|
<ScrollView className="p-4" showsVerticalScrollIndicator={false}>
|
|
<Text className="text-lg font-black text-white uppercase tracking-wider mb-4">Biểu Đồ Xu Hướng</Text>
|
|
|
|
<View className="flex-row gap-3 mb-6">
|
|
<View className="flex-1 bg-slate-950 border border-slate-900 rounded-2xl p-4">
|
|
<Text className="text-[9px] text-slate-500 font-extrabold uppercase">Tổng quãng đường</Text>
|
|
<Text className="text-2xl font-black text-emerald-400 mt-1">{totalKm.toFixed(1)} <Text class="text-xs">km</Text></Text>
|
|
</View>
|
|
<View class="flex-1 bg-slate-950 border border-slate-900 rounded-2xl p-4">
|
|
<Text className="text-[9px] text-slate-500 font-extrabold uppercase">Năng lượng tiêu hao</Text>
|
|
<Text className="text-2xl font-black text-orange-500 mt-1">{totalKcal} <Text class="text-xs">Kcal</Text></Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="bg-slate-950 border border-slate-900 rounded-3xl p-4 mb-6">
|
|
<Text className="text-xs font-bold text-white mb-4 uppercase tracking-wider">Tiến trình tích lũy cự ly</Text>
|
|
<LineChart
|
|
data={{
|
|
labels: ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN'],
|
|
datasets: [{ data: [12, 18, 15, 25, 30, 48, totalKm > 0 ? totalKm : 10] }]
|
|
}}
|
|
width={screenWidth - 40}
|
|
height={180}
|
|
chartConfig={chartConfig}
|
|
bezier
|
|
style={{ borderRadius: 16 }}
|
|
/>
|
|
</View>
|
|
|
|
<View className="bg-slate-950 border border-slate-900 rounded-3xl p-4 mb-6">
|
|
<Text className="text-xs font-bold text-white mb-3 uppercase tracking-wider">Tỷ lệ hình thức tập thể dục</Text>
|
|
{sportDistribution.length > 0 ? (
|
|
<PieChart
|
|
data={sportDistribution}
|
|
width={screenWidth - 40}
|
|
height={150}
|
|
chartConfig={chartConfig}
|
|
accessor="population"
|
|
backgroundColor="transparent"
|
|
paddingLeft="15"
|
|
absolute
|
|
/>
|
|
) : (
|
|
<Text className="text-xs text-slate-500 text-center py-6">Chưa có dữ liệu phân loại môn thể thao</Text>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const chartConfig = {
|
|
backgroundGradientFrom: '#050811',
|
|
backgroundGradientTo: '#090d16',
|
|
color: (opacity = 1) => `rgba(16, 185, 129, ${opacity})`,
|
|
labelColor: (opacity = 1) => `rgba(148, 163, 184, ${opacity})`,
|
|
strokeWidth: 3,
|
|
decimalPlaces: 0,
|
|
};
|
|
### D. Màn hình Lịch sự kiện (HistoryScreen.tsx)
|
|
|
|
Nhật ký Lịch hiển thị thông tin trực quan, tự động truy vấn dữ liệu từ SQLite để bôi màu các ngày có lịch sử luyện tập, cho phép nhấn chọn ngày cụ thể để lọc danh sách các hoạt động tương ứng.
|
|
|
|
|
|
// src/app/(tabs)/history.tsx
|
|
import React, { useEffect, useState } from 'react';
|
|
import { View, Text, TouchableOpacity, FlatList, SafeAreaView } from 'react-native';
|
|
import { Calendar } from 'react-native-calendars';
|
|
import { getDBConnection } from '@/database/sqlite';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
|
|
export default function HistoryScreen() {
|
|
const router = useRouter();
|
|
const [workouts, setWorkouts] = useState<any[]>([]);
|
|
const [markedDates, setMarkedDates] = useState<any>({});
|
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
|
|
|
useEffect(() => {
|
|
loadCalendarActivities();
|
|
}, []);
|
|
|
|
const loadCalendarActivities = async () => {
|
|
const db = await getDBConnection();
|
|
const rows: any[] = await db.getAllAsync('SELECT id, date, type, distance FROM workouts');
|
|
|
|
const marks: any = {};
|
|
rows.forEach(w => {
|
|
marks[w.date] = {
|
|
marked: true,
|
|
dotColor: w.type === 'CYCLING' ? '#4f46e5' : '#10b981',
|
|
activeOpacity: 0.8
|
|
};
|
|
});
|
|
setMarkedDates(marks);
|
|
fetchWorkoutsByDate(selectedDate);
|
|
};
|
|
|
|
const fetchWorkoutsByDate = async (date: string) => {
|
|
setSelectedDate(date);
|
|
const db = await getDBConnection();
|
|
const rows: any[] = await db.getAllAsync(
|
|
'SELECT * FROM workouts WHERE date = ?',
|
|
[date]
|
|
);
|
|
setWorkouts(rows);
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-[#050811]">
|
|
<View className="p-4 flex-1">
|
|
<Text className="text-lg font-black text-white uppercase mb-4 tracking-wider">Nhật Ký Tập Luyện</Text>
|
|
|
|
<View className="bg-slate-950 border border-slate-900 rounded-3xl overflow-hidden p-3 mb-4">
|
|
<Calendar
|
|
theme={calendarDarkTheme}
|
|
markedDates={{
|
|
...markedDates,
|
|
[selectedDate]: {
|
|
...markedDates[selectedDate],
|
|
selected: true,
|
|
selectedColor: '#10b981'
|
|
}
|
|
}}
|
|
onDayPress={(day) => fetchWorkoutsByDate(day.dateString)}
|
|
/>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={workouts}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<TouchableOpacity
|
|
onPress={() => router.push(`/replay/${item.id}`)}
|
|
className="bg-slate-950 border border-slate-900 rounded-2xl p-4 flex-row items-center justify-between mb-2.5"
|
|
>
|
|
<View className="flex-row items-center gap-3">
|
|
<View className="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center">
|
|
<Ionicons
|
|
name={item.type === 'CYCLING' ? 'bicycle' : 'walk'}
|
|
size={20}
|
|
color={item.type === 'CYCLING' ? '#4f46e5' : '#10b981'}
|
|
/>
|
|
</View>
|
|
<View>
|
|
<Text className="text-xs font-black text-white">{item.title}</Text>
|
|
<Text className="text-[10px] text-slate-500 mt-1">Cự ly: {item.distance.toFixed(2)} km</Text>
|
|
</View>
|
|
</View>
|
|
<Ionicons name="play-circle" size={24} color="#10b981" />
|
|
</TouchableOpacity>
|
|
)}
|
|
ListEmptyComponent={
|
|
<Text className="text-xs text-slate-500 text-center py-8">Không có hoạt động nào trong ngày được chọn.</Text>
|
|
}
|
|
/>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const calendarDarkTheme = {
|
|
backgroundColor: '#050811',
|
|
calendarBackground: '#050811',
|
|
textSectionTitleColor: '#64748b',
|
|
selectedDayBackgroundColor: '#10b981',
|
|
selectedDayTextColor: '#090d16',
|
|
todayTextColor: '#10b981',
|
|
dayTextColor: '#cbd5e1',
|
|
textDisabledColor: '#334155',
|
|
dotColor: '#10b981',
|
|
selectedDotColor: '#090d16',
|
|
arrowColor: '#10b981',
|
|
monthTextColor: '#ffffff',
|
|
textMonthFontWeight: 'bold',
|
|
textDayFontSize: 12,
|
|
textMonthFontSize: 14,
|
|
};
|
|
## 8. LỘ TRÌNH PHÁT TRIỂN & CHUẨN BỊ PUBLISH (CHAMPION CHECKLIST)
|
|
### A. Tối ưu hóa trước khi xuất xưởng
|
|
|
|
#### iOS (App Store): Bắt buộc phải thêm khóa cấu hình NSLocationAlwaysAndWhenInUseUsageDescription vào Info.plist và viết kịch bản chi tiết cách ứng dụng định vị chạy ngầm để nhân viên Apple phê duyệt khi kiểm thử (Review)
|
|
#### .Android (Google Play): Từ phiên bản Android 10 trở đi, quyền ACCESS_BACKGROUND_LOCATION thuộc nhóm quyền nhạy cảm (Dangerous Permission). Bạn cần quay một đoạn video ngắn mô phỏng tính năng "Ghi GPS chạy ngầm khi màn hình tắt" để upload lên Google Play Console làm minh chứng duyệt ứng dụng.
|
|
|
|
### B. Mở rộng kết nối ESP32 / Cảm biến Nhịp tim (BLE Setup)
|
|
|
|
Nhờ cấu trúc mã nguồn sử dụng React Native Native API, ở các giai đoạn nâng cấp tiếp theo, bạn có thể tích hợp trực tiếp thư viện react-native-ble-plx để quét tìm, kết nối trực tiếp với cảm biến đeo ngực bluetooth hoặc bo mạch điều khiển tự chế ESP32 nhằm ghi nhận nhịp tim thực tế của người tập thay vì nhịp tim giả lập.
|
|
Sử dụng cấu trúc hook useBluetoothBLE.ts đã được khởi tạo trong sơ đồ thư mục của lớp /src/hooks/ để viết logic quét UUID kết nối đặc trưng với bo mạch ESP32. |