iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Build on Google AI

單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?系列 第 19

Day 19 | 介面實作 (2):告別生硬 JSON!拆解 UI 元件並完美綁定 AI 課表資料

  • 分享至 

  • xImage
  •  

前言:骨架有了,現在為它注入靈魂

在 Day 17 時,我們已經勾勒出了課表頁面的基本輪廓;昨天 (Day 18) 更是透過 Zustand 成功打造了 APP 的全域大腦,讓真實從 Gemini API 呼叫回來並產生好的計畫,不再隨 APP 關閉而消失,並成功在畫面上印出了跑者的基本資料與一長串的 JSON 字串。

但要了解:「再厲害的 AI 演算法,如果沒有好的包裝,使用者也無法感受到它的價值。」
今天,我們要正式告別畫面上生硬的 JSON 字串!我們將把存在 Zustand 記憶體中的真實 aiPlan 進行完美解析,並將原本寫在同一頁的龐大程式碼,乾淨俐落地拆解為三大視覺核心:

  1. AI 教練評估卡片 (Dark Card):深色區塊,展示賽事目標與教練叮嚀。
  2. 週次切換器 (Week Selector):利用 Zustand 狀態控制的水平導覽列。
  3. 每日訓練清單 (Daily Cards):乾淨明亮的卡片列表,動態列出每日的跑量與課表內容。

架構思考:全馬 16~20 週 vs 4 週微週期的階段規劃

像我這樣的普通人,準備全馬至少需要 16 至 20 週。但若讓單一 Prompt 一次吐出 20 週、140 天的課表,會面臨 Token 上限、長耗時等待與輸出截斷的風險。因此我們的全端策略如下:

  • 第二階段(Day 18 ~ 22 - 前端與狀態核心):先鎖定在 4 週(1 個月)的基礎適應期。單次生成僅需 8~12 秒,讓我們能快速除錯與驗證 Zustand 狀態流轉。
  • 第三階段(Phase 3 - 全端 UX 大升級):屆時我們將一口氣進行架構躍進,後端升級為完整動態週期生成,前端也將同步導入真實日曆,呈現更專業的使用者體驗!

觀念解說:JSON 資料結構對齊 (Data Mapping)

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 元件上。

動手實作 1:打造深色 AI 評估卡片 (AiSummaryCard)

我們將 Day 17 切好的深色卡片獨立成一個元件,並讓它接收 aiPlan 的資料來動態替換文字。你也可以直接將需求餵給 AI,請它幫你完成組裝:

「請幫我將 AiSummaryCard 獨立成一個 React Native 元件。
資料來源:接收 aiPlan 物件作為 Props。
設計規範:深藍黑色背景 (#1E293B)。頂部左側淺藍標籤『X 週週期計畫』,右側顯示系統日期。大標題讀取 targetRace。中間淺藍灰區塊搭配燈泡 Icon 顯示 evaluation 教練叮嚀。請提供完整程式碼。」

動手實作 2:週次切換器與狀態綁定 (WeekSelector)

接下來是中間的週次按鈕。這裡不再是純視覺,我們利用 Zustand 的 selectedWeeksetSelectedWeek 來動態切換「橘色 (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>
  );
}
// (樣式程式碼保留你原本的設定即可)

動手實作 3:陣列過濾,渲染每日訓練卡片 (DailyWorkoutList)

最後,我們需要根據使用者點擊的 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

拆解完成後,原本擁擠的 ScheduleScreen.tsx 瞬間變得極度優雅且易於維護:

{hasPlan && (
  <View style={styles.planContainer}>
    {/* 1. AI 教練評估深色卡片 */}
    <AiSummaryCard aiPlan={aiPlan} />

    {/* 2. 週次切換器 */}
    <WeekSelector totalWeeks={totalWeeks} />

    {/* 3. 每日訓練清單 */}
    <DailyWorkoutList />
  </View>
)}

最後完成的結果如下,其實看起來很像,但背後的元件邏輯已做更新!
https://ithelp.ithome.com.tw/upload/images/20260828/20165043TFuUtSYqtI.png

https://ithelp.ithome.com.tw/upload/images/20260828/201650435UVLJumb3w.png

今日總結與明日預告

今天我們運用前端的元件化思維,將真實且複雜的 JSON 結構優雅地拆解、過濾,並綁定到獨立的 UI 元件上,成功將生硬的 API 回應化為充滿質感的訓練介面。

看著這份課表,跑者已經蓄勢待發了。但是,計畫總趕不上變化。如果跑者明天突然要加班,或是週末參加了一場激烈的網球賽導致大腿嚴重痠痛,這份「固定」的課表不就失去意義了嗎?

明天(Day 20)將迎來第二階段的壓軸好戲:打造「AI 教練診療室」!我們將實作疲勞度打卡與文字發問介面,為後續的「動態課表重算」做好萬全準備!


上一篇
Day 18 | 打造教練的海馬迴:Zustand 與 Persist 持久化實戰
下一篇
Day 20 | 介面實作 (3):打造課表診療室,實作疲勞度打卡與文字發問介面
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言