昨天針對「AI 對話式行程編輯器」進行了完整的設計與規劃,從使用者痛點、功能目標到 API 資料流和程式碼架構,都已經有了清晰的藍圖。今天的目標很單純就是一直寫程式、一直寫程式、一直寫程式把畫面做完串完假資料~
首先,按照昨天的規劃,將現有的畫面進行調整。
trip_detail_view.dart
)這個頁面是使用者瀏覽單一行程的起點。為了讓使用者能隨時啟動 AI 編輯功能,將在畫面的右下角新增一個 Floating Action Button (FAB)。
當使用者點擊這個按鈕時,應用程式會導航至專為 AI 聊天設計的新介面。這個實作很直覺,只需在 Scaffold
中加入 floatingActionButton
屬性即可。
另外,由於頁面上已有一個手動新增活動的 FAB,為了避免兩個按鈕的 Hero 動畫發生衝突,必須為新加入的 FAB 指定一個獨特的 heroTag
。這個標籤能幫助 Flutter 區分它們,確保動畫流暢且不會出錯。
// trip_detail_view.dart
class TripDetailView extends ConsumerWidget {
final Trip trip;
// ... 其他程式碼
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text(trip.title)),
// ... 其他頁面內容
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.medium,
children: [
AppFloatingActionButton(
heroTag: 'ai_chat_fab',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AIChatView(tripId: tripId), // AI 助理聊天頁面
),
),
label: 'AI',
icon: 'chat-bubble',
),
AppFloatingActionButton(
heroTag: 'add_activity_fab',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ActivityEditorView(tripId: tripId, activity: null),
),
),
label: 'Add Activity',
icon: 'add',
),
],
),
);
}
}
ai_chat_view.dart
)這是今天開發的核心。這個畫面將會是一個獨立的聊天室,用來展示使用者與 AI 的對話。
整個畫面由以下幾個主要部分組成:
AppBar
:顯示頁面標題。ListView.builder
:用來動態產生聊天訊息列表。將根據 chatProvider
的狀態來渲染訊息。TextField
和一個「送出」按鈕,讓使用者可以輸入文字。以下是 ai_chat_view.dart
的骨架程式碼:
// ai/chat/ai_chat_view.dart
class _ChatPageState extends ConsumerState<AIChatView> {
final _scrollController = ScrollController();
void _scrollToBottom() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<AppColorExtension>()!;
final messages = ref.watch(chatControllerProvider(widget.tripId));
// 只要 messages 長度有變化,就自動捲到底
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
return Scaffold(
// ... 其他頁面內容
body: Column(
children: [
Expanded(
child: _ChatList(
tripId: widget.tripId,
messages: messages,
scrollController: _scrollController,
),
),
SafeArea(top: false, child: ChatInputBar(tripId: widget.tripId)),
],
),
);
}
}
昨天規劃中提到,當 AI 回傳 itinerary
參數時,我們會顯示一個 「預覽行程」按鈕。今天我們可以將這個邏輯實作在我們的 ChatMessageBubble
元件中。
當 AI 訊息的 itinerary
欄位不為空時,就在訊息泡泡下方顯示一個按鈕。點擊後,導航至新的 AiTripDetailView
。這個預覽畫面可以重用大部分的 TripDetailView
邏輯,只是資料來源變成了 AI 提供的 JSON。
// widgets/chat_message_bubble.dart
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message.text, style: textStyle),
const SizedBox(height: AppSpacing.small),
PrimaryButton(
text: '預覽行程',
size: AppButtonSize.medium,
onPressed: () {
ref
.read(aiTripListProvider.notifier)
.previewAITrip(trip: message.itinerary!);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => AITripDetailView()),
);
},
),
],
);
}
在 FakeChatRepository
中準備好了假資料,並將它與 ChatController
串接起來,讓聊天功能能夠模擬運作。ChatController
的 sendText
方法將會是處理使用者輸入的關鍵。這個方法會執行以下步驟:
_repo.streamReply()
方法,模擬 AI 逐字回覆的行為。Future<void> sendText(String text) async {
// 串流回覆
_streamSub?.cancel();
_streamSub = _repo
.streamReply(sessionId: _convId, text: text, itinerary: itinerary)
.listen(
(chunk) {
// 逐段更新最後一則 AI 訊息
final lastIndex = state.lastIndexWhere((m) => m.id == aiPending.id);
if (lastIndex == -1) return;
final updated = state[lastIndex].copyWith(
pending: false,
text: state[lastIndex].text + chunk.text,
itinerary: chunk.itinerary ?? state[lastIndex].itinerary,
);
final newList = [...state];
newList[lastIndex] = updated;
state = newList;
_convId = chunk.sessionId;
},
onError: (e, st) {
// 錯誤處理
},
);
}
經過上述的實作,現在已經有一個能夠運作的 AI 聊天編輯器雛形了。雖然後端還未正式串接,但透過 FakeChatRepository
的模擬,我們可以清楚地看到整個流程。