昨天成功把 JSON 轉化為極具質感的課表 UI。看著 4 週訓練計畫,感覺跑者隨時都可以準備跑了。
但在實際狀況裡,備賽過程從來不是一帆風順的。比如晚上突然要臨時加班到半夜只能跑 20 分鐘;或是週末去打了一場高強度網球賽導致大腿痠痛無法下樓梯。此時若強行執行原本 14 公里的配速跑,極易導致受傷。
傳統的 PDF 課表無法因應突發狀況。但 KAKERU 擁有 Gemini AI 大腦!今天我們要打造「AI 教練診療室 (Clinic 頁面)」,收集跑者的生理疲勞與生活突發事件,為明天的「動態重算課表」鋪路!
POST 協議在後端架構中,動態調整課表走的是專屬端點 POST /adjust。為了讓 AI 能精準重算剩餘課表,前端在診療室需要打包以下 Context:
ClinicScreen)在 src/app/clinic.tsx 中,我們定義 1~5 分疲勞度與涵蓋「生理疲勞」及「生活突發」的快捷標籤:
const FATIGUE_OPTIONS = [
{ level: 1, label: '極佳', emoji: '🤩', desc: '體能充沛' },
{ level: 2, label: '良好', emoji: '🙂', desc: '狀態良好' },
{ level: 3, label: '普通', emoji: '😐', desc: '正常體能' },
{ level: 4, label: '疲勞', emoji: '😮💨', desc: '肌肉痠緊' },
{ level: 5, label: '極累', emoji: '😫', desc: '體能透支' },
];
const QUICK_TAGS = [
'💻 臨時加班,今天只能跑 30 分鐘',
'🎾 週末打球大腿極度痠痛,想改休息',
'🦵 右膝外側微痛,想改做核心與伸展',
'🌧️ 外面下大雨且無跑步機,想改室內訓練',
'✈️ 明後天出差聚會,想將長距離移到週末',
];
export default function ClinicScreen() {
const [fatigueLevel, setFatigueLevel] = useState(3);
const [feedbackText, setFeedbackText] = useState('');
return (
<View style={styles.section}>
{/* 1. 疲勞度按鈕列 */}
<View style={styles.emojiContainer}>
{FATIGUE_OPTIONS.map((item) => (
<TouchableOpacity
key={item.level}
style={[styles.emojiButton, fatigueLevel === item.level && styles.emojiButtonActive]}
onPress={() => setFatigueLevel(item.level)}
>
<Text style={styles.emojiIcon}>{item.emoji}</Text>
<Text style={[styles.emojiLabel, fatigueLevel === item.level && styles.emojiLabelActive]}>
{item.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* 2. 生活突發與痠痛快捷標籤 (橫向滑動節省空間) */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.quickTagsScroll}>
{QUICK_TAGS.map((tag, idx) => (
<TouchableOpacity key={idx} style={styles.quickTagChip} onPress={() => setFeedbackText(tag)}>
<Text style={styles.quickTagText}>+ {tag}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
為了防止手機鍵盤彈出時遮擋輸入框,我們在 ScrollView 啟用了原生 automaticallyAdjustKeyboardInsets 與 keyboardDismissMode="on-drag",並在送出時將資料打包為符合後端 AdjustScheduleRequest 的格式:
const currentPlan = useStore((state) => state.currentPlan);
const selectedWeek = useStore((state) => state.selectedWeek);
const sessionId = useStore((state) => state.sessionId);
const handleSubmitCheckin = () => {
const selectedOption = FATIGUE_OPTIONS.find((o) => o.level === fatigueLevel);
const dayOfWeek = new Date().getDay() === 0 ? 7 : new Date().getDay(); // 1=週一 ... 7=週日
// 封裝完全對齊後端 POST /adjust 協議
const adjustPayload = {
sessionId: sessionId || undefined,
originalScheduleJson: JSON.stringify(currentPlan?.data || {}),
userFeedback: `[疲勞度: ${fatigueLevel}/5 - ${selectedOption?.label}] ${feedbackText.trim() || '因應今日狀況請微調'}`,
currentWeek: selectedWeek || 1,
currentDay: dayOfWeek,
};
console.log('🩺 打包完成,準備發送給 AI 教練:', adjustPayload);
};
const styles = StyleSheet.create({
emojiContainer: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
emojiButton: { flex: 1, alignItems: 'center', paddingVertical: 14, borderRadius: 14, backgroundColor: '#FFF', borderWidth: 1, borderColor: '#E5E7EB' },
emojiButtonActive: { backgroundColor: '#FFF0EB', borderColor: '#FF5722' },
emojiLabelActive: { color: '#FF5722', fontWeight: '800' },
quickTagsScroll: { flexDirection: 'row', gap: 8, paddingVertical: 2 },
quickTagChip: { backgroundColor: '#FFF', borderWidth: 1, borderColor: '#E2E8F0', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 12 },
textArea: { backgroundColor: '#FFF', borderRadius: 14, padding: 16, minHeight: 120, borderWidth: 1, borderColor: '#E5E7EB' },
submitButton: { backgroundColor: '#1E2430', paddingVertical: 16, borderRadius: 14, alignItems: 'center' },
submitButtonText: { color: '#FFF', fontSize: 16, fontWeight: '800' },
});
現在打開 Expo Go 點擊「診療室」分頁,就能看到疲勞度表情按鈕、橫向快捷情境標籤與流暢不遮擋的文字框!
介面如下:
點選調整課表後:
今天打造了「AI 教練診療室」介面,不僅涵蓋了生理疲勞回報,更兼顧了跑者日常中常遇到的「臨時加班、出差」等突發狀況,並在前端將通訊協定封裝就緒。
明天(Day 21)將正式呼叫 Cloud Run 上的 API 端點! 當後端 Gemini 接收到「臨時加班 / 大腿痠痛」的請求後,會如何重新動態調整後續課表?重算後的資料又該如何無縫刷新回 Zustand 與手機畫面?我們明天見真章!
(午餐時間)
友人:今天要吃啥?
:今天得餓死…
友人:蛤為什麼要餓死,back number演唱會周邊買太多?
:今天鐵人賽 Day20。
友人:...
友人:0分超爛,去餓薯好了。