diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 7c649c2..b69b75e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,15 +1,168 @@ import { HomeHeader } from "@/components/home-header"; +import { MatchCard } from "@/components/match-card"; +import { SelectionModal } from "@/components/selection-modal"; +import { CalendarModal } from "@/components/simple-calendar"; +import { ThemedText } from "@/components/themed-text"; import { ThemedView } from "@/components/themed-view"; -import React from "react"; -import { ScrollView, StyleSheet } from "react-native"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import { fetchSports, fetchTodayMatches } from "@/lib/api"; +import { Match, Sport } from "@/types/api"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + FlatList, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; export default function HomeScreen() { + const { theme } = useTheme(); + const { t } = useTranslation(); + const isDark = theme === "dark"; + const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; + const filterBg = isDark ? "#2C2C2E" : "#F2F2F7"; + + const [sports, setSports] = useState([]); + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(true); + + // Selection States + const [selectedSportId, setSelectedSportId] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date()); + + // Modal Visibilities + const [showSportModal, setShowSportModal] = useState(false); + const [showCalendarModal, setShowCalendarModal] = useState(false); + + // Load Sports + useEffect(() => { + loadSports(); + }, []); + + // Load Matches when sport or date changes + useEffect(() => { + if (selectedSportId !== null) { + loadMatches(selectedSportId); + } + }, [selectedSportId, selectedDate]); + + const loadSports = async () => { + try { + const list = await fetchSports(); + setSports(list); + if (list.length > 0) { + setSelectedSportId(list[0].id); + } + } catch (e) { + console.error(e); + } + }; + + const loadMatches = async (sportId: number) => { + setLoading(true); + try { + // Pass selectedDate if API supported it + const list = await fetchTodayMatches(sportId); + setMatches(list); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const currentSport = sports.find((s) => s.id === selectedSportId); + + const renderHeader = () => ( + + {/* Time Filter (Mock) */} + + + {t("home.time")} + + + {/* Sport Selector */} + setShowSportModal(true)} + > + + + {currentSport ? currentSport.name : t("home.select_sport")} + + + + {/* Date Selector */} + setShowCalendarModal(true)} + > + + {selectedDate.getDate()} + + + {selectedDate.getHours()}: + {selectedDate.getMinutes().toString().padStart(2, "0")} + + + + ); + return ( - - {/* Placeholder for future content */} - + + {renderHeader()} + + {loading ? ( + + + {t("home.loading")} + + ) : ( + item.id} + renderItem={({ item }) => } + contentContainerStyle={styles.listContent} + ListEmptyComponent={ + + {t("home.no_matches")} + + } + /> + )} + + {/* Modals */} + setShowSportModal(false)} + title={t("home.select_sport")} + options={sports.map((s) => ({ + id: s.id, + label: s.name, + value: s.id, + }))} + selectedValue={selectedSportId} + onSelect={setSelectedSportId} + /> + + setShowCalendarModal(false)} + selectedDate={selectedDate} + onSelectDate={setSelectedDate} + /> ); } @@ -18,7 +171,52 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - content: { + center: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingTop: 50, + }, + filterContainer: { + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 12, + gap: 12, + }, + filterBtn: { + flex: 1, + height: 44, // Increased from 36 + flexDirection: "column", // Stacked logic for Date, or Row for others + justifyContent: "center", + alignItems: "center", + borderRadius: 8, // Rounded corners + // iOS shadow + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + // Android elevation + elevation: 2, + }, + mainFilterBtn: { + flex: 2, // Wider for sport + flexDirection: "row", + gap: 8, + }, + filterText: { + fontSize: 14, + fontWeight: "500", + }, + dateDayText: { + fontSize: 16, + fontWeight: "bold", + }, + dateMonthText: { + fontSize: 10, + opacity: 0.6, + }, + listContent: { padding: 16, + paddingTop: 8, }, }); diff --git a/components/match-card.tsx b/components/match-card.tsx new file mode 100644 index 0000000..a743bb9 --- /dev/null +++ b/components/match-card.tsx @@ -0,0 +1,110 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import { Match } from "@/types/api"; +import React from "react"; +import { StyleSheet, View } from "react-native"; + +interface MatchCardProps { + match: Match; +} + +export function MatchCard({ match }: MatchCardProps) { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; + const cardBg = isDark ? "#1C1C1E" : "#FFFFFF"; + const borderColor = isDark ? "#38383A" : "#E5E5EA"; + + return ( + + + + + {match.league} + + + {match.time} + + + + + {match.home} vs {match.away} + + + + {match.scoreText} + + + + + + {match.meta && ( + {match.meta} + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + padding: 12, + marginBottom: 12, + borderRadius: 12, + borderWidth: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 8, + }, + leagueBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + marginRight: 8, + maxWidth: "70%", + }, + leagueText: { + fontSize: 12, + opacity: 0.7, + }, + timeText: { + fontSize: 12, + opacity: 0.5, + }, + teamsContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 4, + }, + teamsText: { + fontSize: 16, + flex: 1, + marginRight: 8, + }, + scoreContainer: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + scoreText: { + fontSize: 16, + }, + metaText: { + fontSize: 12, + opacity: 0.5, + marginTop: 4, + }, +}); diff --git a/components/selection-modal.tsx b/components/selection-modal.tsx new file mode 100644 index 0000000..73f9642 --- /dev/null +++ b/components/selection-modal.tsx @@ -0,0 +1,111 @@ +import { ThemedText } from "@/components/themed-text"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import React from "react"; +import { Modal, Pressable, ScrollView, StyleSheet, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +interface SelectionOption { + id: number | string; + label: string; + value: any; +} + +interface SelectionModalProps { + visible: boolean; + onClose: () => void; + title: string; + options: SelectionOption[]; + onSelect: (value: any) => void; + selectedValue: any; +} + +export function SelectionModal({ + visible, + onClose, + title, + options, + onSelect, + selectedValue, +}: SelectionModalProps) { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const bg = isDark ? "#1C1C1E" : "#FFFFFF"; + const text = isDark ? "#FFFFFF" : "#000000"; + + return ( + + + + + + + + + {title} + + + Done + + + + + {options.map((opt) => ( + { + onSelect(opt.value); + onClose(); + }} + > + + {opt.label} + + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + }, + sheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + padding: 16, + maxHeight: "60%", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + option: { + paddingVertical: 16, + paddingHorizontal: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#ccc", + }, +}); diff --git a/components/simple-calendar.tsx b/components/simple-calendar.tsx new file mode 100644 index 0000000..895e53a --- /dev/null +++ b/components/simple-calendar.tsx @@ -0,0 +1,194 @@ +import { ThemedText } from "@/components/themed-text"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import React, { useMemo, useState } from "react"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; + +interface CalendarModalProps { + visible: boolean; + onClose: () => void; + selectedDate: Date; + onSelectDate: (date: Date) => void; +} + +export function CalendarModal({ + visible, + onClose, + selectedDate, + onSelectDate, +}: CalendarModalProps) { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const bg = isDark ? "#1C1C1E" : "#FFFFFF"; + + const [currentMonth, setCurrentMonth] = useState(new Date(selectedDate)); + + const daysInMonth = useMemo(() => { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + const date = new Date(year, month, 1); + const days = []; + while (date.getMonth() === month) { + days.push(new Date(date)); + date.setDate(date.getDate() + 1); + } + return days; + }, [currentMonth]); + + const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + // Add empty slots for start of month + const startDay = daysInMonth[0]?.getDay() || 0; + const blanks = Array(startDay).fill(null); + + const handleDayPress = (date: Date) => { + onSelectDate(date); + onClose(); + }; + + return ( + + + + {/* Header */} + + + setCurrentMonth( + new Date( + currentMonth.getFullYear(), + currentMonth.getMonth() - 1, + 1 + ) + ) + } + > + {"<"} + + + {currentMonth.toLocaleString("default", { + month: "long", + year: "numeric", + })} + + + setCurrentMonth( + new Date( + currentMonth.getFullYear(), + currentMonth.getMonth() + 1, + 1 + ) + ) + } + > + {">"} + + + + {/* Week Headers */} + + {weekDays.map((day) => ( + + {day} + + ))} + + + {/* Days Grid */} + + {blanks.map((_, index) => ( + + ))} + {daysInMonth.map((date) => { + const isSelected = + date.getDate() === selectedDate.getDate() && + date.getMonth() === selectedDate.getMonth() && + date.getFullYear() === selectedDate.getFullYear(); + return ( + handleDayPress(date)} + > + + {date.getDate()} + + + ); + })} + + + + Close + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "center", + padding: 20, + }, + calendarContainer: { + borderRadius: 16, + padding: 20, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + navText: { + fontSize: 20, + padding: 10, + }, + weekRow: { + flexDirection: "row", + justifyContent: "space-around", + marginBottom: 10, + }, + weekDayText: { + opacity: 0.5, + width: 30, + textAlign: "center", + fontSize: 12, + }, + daysGrid: { + flexDirection: "row", + flexWrap: "wrap", + }, + dayCell: { + width: "14.28%", // 100% / 7 + aspectRatio: 1, + justifyContent: "center", + alignItems: "center", + marginBottom: 5, + }, + closeBtn: { + alignItems: "center", + marginTop: 20, + padding: 10, + }, +}); diff --git a/constants/api.ts b/constants/api.ts new file mode 100644 index 0000000..a5fca7b --- /dev/null +++ b/constants/api.ts @@ -0,0 +1,9 @@ +export const API_CONFIG = { + BASE_URL: "http://192.168.1.66:8000", // Update this with your machine IP if running on device ex: http://192.168.1.5:8000 + TIMEOUT: 10000, +}; + +export const API_ENDPOINTS = { + SPORTS: "/v1/api/sports", + MATCHES_TODAY: "/v1/api/matches/today", +}; diff --git a/docs/开发文档.md b/docs/开发文档.md new file mode 100644 index 0000000..ed9c2be --- /dev/null +++ b/docs/开发文档.md @@ -0,0 +1,72 @@ +开发地址后端 本地8000端口 + +获取所有启用的体育项目列表 +/v1/api/sports + +{ + "code": 0, + "message": "ok", + "data": { + "list": [ + { + "id": 1, + "name": "足球", + "description": "足球", + "icon": "", + "isActive": false, + "updatedAt": "", + "createdAt": "" + }, + { + "id": 2, + "name": "篮球", + "description": "篮球", + "icon": "", + "isActive": false, + "updatedAt": "", + "createdAt": "" + } + ], + "total": 4 + } +} + + +/v1/api/matches/today +Parameters +Cancel +Name Description +sport_id * +integer +(query) +体育项目ID (1:足球, 2:篮球, 3:网球, 4:板球) + + +{ + "code": 0, + "message": "ok", + "data": { + "list": [ + { + "id": "27", + "league": "EFL Trophy - 1/8-finals", + "time": "20:00", + "home": "AFC Wimbledon", + "away": "West Ham U21", + "meta": "The Cherry Red Records Stadium", + "scoreText": "-", + "fav": false + }, + { + "id": "241", + "league": "Division di Honor", + "time": "01:00", + "home": "Estrella", + "away": "Dakota", + "scoreText": "-", + "fav": false + } + ], + "total": 78 + } +} \ No newline at end of file diff --git a/i18n/locales/en.json b/i18n/locales/en.json index ff44367..d51bd25 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -18,7 +18,11 @@ "chinese": "Chinese" }, "home": { - "title": "Physical" + "title": "ScoreNow", + "time": "Time", + "select_sport": "Sport", + "loading": "Loading...", + "no_matches": "No matches found." }, "profile": { "title": "My Profile", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index ae3fd2e..e35417e 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -18,7 +18,11 @@ "chinese": "中文" }, "home": { - "title": "Physical" + "title": "ScoreNow", + "time": "时间", + "select_sport": "运动", + "loading": "加载中...", + "no_matches": "暂无比赛" }, "profile": { "title": "我的", diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..1f95d9f --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,46 @@ +import { API_CONFIG, API_ENDPOINTS } from "@/constants/api"; +import { ApiResponse, Match, Sport } from "@/types/api"; +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: API_CONFIG.BASE_URL, + timeout: API_CONFIG.TIMEOUT, + headers: { + "Content-Type": "application/json", + }, +}); + +export const fetchSports = async (): Promise => { + try { + const response = await apiClient.get>( + API_ENDPOINTS.SPORTS + ); + if (response.data.code === 0) { + return response.data.data.list; + } + throw new Error(response.data.message); + } catch (error) { + console.error("Fetch sports error:", error); + // Do not return mock data here — rethrow to let caller handle the error + throw error; + } +}; + +export const fetchTodayMatches = async (sportId: number): Promise => { + try { + const response = await apiClient.get>( + API_ENDPOINTS.MATCHES_TODAY, + { + params: { sport_id: sportId }, + } + ); + if (response.data.code === 0) { + return response.data.data.list; + } + throw new Error(response.data.message); + } catch (error) { + console.error("Fetch matches error:", error); + // Let the caller handle errors; rethrow the original error + throw error; + } +}; diff --git a/types/api.ts b/types/api.ts new file mode 100644 index 0000000..96f91e2 --- /dev/null +++ b/types/api.ts @@ -0,0 +1,29 @@ +export interface Sport { + id: number; + name: string; + description: string; + icon: string; + isActive: boolean; + updatedAt: string; + createdAt: string; +} + +export interface Match { + id: string; + league: string; + time: string; + home: string; + away: string; + meta?: string; + scoreText: string; + fav: boolean; +} + +export interface ApiResponse { + code: number; + message: string; + data: { + list: T[]; + total: number; + }; +}