在 Day 17 時,我們已經勾勒出了課表頁面的基本輪廓;昨天 (Day 18) 更是透過 Zustand 成功打造了 APP 的全域大腦,讓真實從 Gemini API 呼叫回來並產生好的計畫,不再隨 APP 關閉而消失,並成功在畫面上印出了跑者的基本資料與一長串的 JSON 字串。
但要了解:「再厲害的 AI 演算法,如果沒有好的包裝,使用者也無法感受到它的價值。」
今天,我們要正式告別畫面上生硬的 JSON 字串!我們將把存在 Zustand 記憶體中的真實 aiPlan 進行完美解析,並將原本寫在同一頁的龐大程式碼,乾淨俐落地拆解為三大視覺核心:
像我這樣的普通人,準備全馬至少需要 16 至 20 週。但若讓單一 Prompt 一次吐出 20 週、140 天的課表,會面臨 Token 上限、長耗時等待與輸出截斷的風險。因此我們的全端策略如下:
Gemini 回傳的真實 aiPlan JSON 結構大致如下:
{
"targetRace": "東京馬拉松 全程馬拉松",
"totalWeeks": 4,
"evaluation": "Takuya 你好!以你 171 cm、68 kg 且每週可訓練 4 天的條件...請嚴格執行每週的休息!",
"schedule": [
{
"week": 1,
"days": [
{ "dayName": "週一", "title": "輕鬆跑", "distance": "6 km", "description": "以輕鬆舒適步伐慢跑..." },
{ "dayName": "週二", "title": "休息", "distance": "-", "description": "完全休息,進行核心伸展。" }
]
}
]
}
我們的任務,就是將這個巢狀結構 (Nested Array),精準映射到對應的 UI 元件上。
我們將 Day 17 切好的深色卡片獨立成一個元件,並讓它接收 aiPlan 的資料來動態替換文字。你也可以直接將需求餵給 AI,請它幫你完成組裝:
「請幫我將 AiSummaryCard 獨立成一個 React Native 元件。
資料來源:接收aiPlan物件作為 Props。
設計規範:深藍黑色背景 (#1E293B)。頂部左側淺藍標籤『X 週週期計畫』,右側顯示系統日期。大標題讀取targetRace。中間淺藍灰區塊搭配燈泡 Icon 顯示evaluation教練叮嚀。請提供完整程式碼。」
接下來是中間的週次按鈕。這裡不再是純視覺,我們利用 Zustand 的 selectedWeek 與 setSelectedWeek 來動態切換「橘色 (Active)」與「淺灰色 (Inactive)」的樣式:
export function WeekSelector({ totalWeeks = 4 }) {
const selectedWeek = useStore((state) => state.selectedWeek);
const setSelectedWeek = useStore((state) => state.setSelectedWeek);
const weeksArray = Array.from({ length: totalWeeks }, (_, i) => i + 1);
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.container}>
{weeksArray.map((weekNum) => {
const isActive = selectedWeek === weekNum;
return (
<TouchableOpacity
key={weekNum}
style={[styles.tabButton, isActive ? styles.activeTab : styles.inactiveTab]}
onPress={() => setSelectedWeek(weekNum)}
>
<Text style={[styles.tabText, isActive ? styles.activeText : styles.inactiveText]}>
第 {weekNum} 週
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
);
}
// (樣式程式碼保留你原本的設定即可)
最後,我們需要根據使用者點擊的 selectedWeek,從龐大的 JSON 中「過濾」出該週的課表,並渲染成 image_2adfe9.jpg 中下方乾淨明亮的白色卡片:
export function DailyWorkoutList() {
const aiPlan = useStore((state) => state.aiPlan);
const selectedWeek = useStore((state) => state.selectedWeek);
if (!aiPlan) return null;
// 1. 精準取得該週資料
const currentWeekDays = aiPlan.schedule?.find((w) => w.week === selectedWeek)?.days || [];
// 2. 渲染每日卡片
return (
<View style={styles.listContainer}>
{currentWeekDays.map((dayData, index) => (
<View key={index} style={styles.dailyCard}>
<View style={styles.cardHeader}>
<View style={styles.dayTag}>
<Text style={styles.dayTagText}>{dayData.dayName}</Text>
</View>
<Text style={styles.workoutTitle}>{dayData.title}</Text>
{dayData.distance !== '-' && (
<View style={styles.distanceTag}>
<Text style={styles.distanceText}>{dayData.distance}</Text>
</View>
)}
</View>
<Text style={styles.workoutDesc}>{dayData.description}</Text>
</View>
))}
</View>
);
}
拆解完成後,原本擁擠的 ScheduleScreen.tsx 瞬間變得極度優雅且易於維護:
{hasPlan && (
<View style={styles.planContainer}>
{/* 1. AI 教練評估深色卡片 */}
<AiSummaryCard aiPlan={aiPlan} />
{/* 2. 週次切換器 */}
<WeekSelector totalWeeks={totalWeeks} />
{/* 3. 每日訓練清單 */}
<DailyWorkoutList />
</View>
)}
最後完成的結果如下,其實看起來很像,但背後的元件邏輯已做更新!

今天我們運用前端的元件化思維,將真實且複雜的 JSON 結構優雅地拆解、過濾,並綁定到獨立的 UI 元件上,成功將生硬的 API 回應化為充滿質感的訓練介面。
看著這份課表,跑者已經蓄勢待發了。但是,計畫總趕不上變化。如果跑者明天突然要加班,或是週末參加了一場激烈的網球賽導致大腿嚴重痠痛,這份「固定」的課表不就失去意義了嗎?
明天(Day 20)將迎來第二階段的壓軸好戲:打造「AI 教練診療室」!我們將實作疲勞度打卡與文字發問介面,為後續的「動態課表重算」做好萬全準備!