Skip to content

Commit 38d57cb

Browse files
YoungHypopeterfriese
authored andcommitted
[FirebaseAI] sync with quickstart-android (#1741)
* feat: refactor main menu layout and clean up chat-related components * Some minor layout fixes * iterate over all TextParts * add navRoute in Sample * fix style in light/dark mode * change Hstack to Label for multi-lines * add .inline for navigationTitleMode
1 parent c39fe8d commit 38d57cb

File tree

12 files changed

+878
-299
lines changed

12 files changed

+878
-299
lines changed

firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj

Lines changed: 295 additions & 216 deletions
Large diffs are not rendered by default.

firebaseai/FirebaseAIExample/ChatExample/Models/ChatMessage.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,21 @@ extension ChatMessage {
7474

7575
static var sample = samples[0]
7676
}
77+
78+
extension ChatMessage {
79+
static func from(_ modelContent: ModelContent) -> ChatMessage? {
80+
// TODO: add non-text parts to message when multi-model support is added
81+
let text = modelContent.parts.compactMap { ($0 as? TextPart)?.text }.joined()
82+
guard !text.isEmpty else {
83+
return nil
84+
}
85+
86+
let participant: Participant = (modelContent.role == "user") ? .user : .system
87+
88+
return ChatMessage(message: text, participant: participant)
89+
}
90+
91+
static func from(_ modelContents: [ModelContent]) -> [ChatMessage] {
92+
return modelContents.compactMap { from($0) }
93+
}
94+
}

firebaseai/FirebaseAIExample/ChatExample/Screens/ConversationScreen.swift

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,16 @@ import SwiftUI
2222

2323
struct ConversationScreen: View {
2424
let firebaseService: FirebaseAI
25-
let title: String
2625
@StateObject var viewModel: ConversationViewModel
2726

2827
@State
2928
private var userPrompt = ""
3029

31-
init(firebaseService: FirebaseAI, title: String, searchGroundingEnabled: Bool = false) {
32-
let model = firebaseService.generativeModel(
33-
modelName: "gemini-2.0-flash-001",
34-
tools: searchGroundingEnabled ? [.googleSearch()] : []
35-
)
36-
self.title = title
30+
init(firebaseService: FirebaseAI, sample: Sample? = nil) {
3731
self.firebaseService = firebaseService
3832
_viewModel =
3933
StateObject(wrappedValue: ConversationViewModel(firebaseService: firebaseService,
40-
model: model))
34+
sample: sample))
4135
}
4236

4337
enum FocusedField: Hashable {
@@ -99,9 +93,14 @@ struct ConversationScreen: View {
9993
}
10094
}
10195
}
102-
.navigationTitle(title)
96+
.navigationTitle(viewModel.title)
97+
.navigationBarTitleDisplayMode(.inline)
10398
.onAppear {
10499
focusedField = .message
100+
// Set initial prompt from viewModel if available
101+
if userPrompt.isEmpty && !viewModel.initialPrompt.isEmpty {
102+
userPrompt = viewModel.initialPrompt
103+
}
105104
}
106105
}
107106

@@ -125,16 +124,17 @@ struct ConversationScreen: View {
125124

126125
private func newChat() {
127126
viewModel.startNewChat()
127+
userPrompt = ""
128128
}
129129
}
130130

131131
struct ConversationScreen_Previews: PreviewProvider {
132132
struct ContainerView: View {
133133
@StateObject var viewModel = ConversationViewModel(firebaseService: FirebaseAI
134-
.firebaseAI()) // Example service init
134+
.firebaseAI(), sample: nil) // Example service init
135135

136136
var body: some View {
137-
ConversationScreen(firebaseService: FirebaseAI.firebaseAI(), title: "Chat sample")
137+
ConversationScreen(firebaseService: FirebaseAI.firebaseAI())
138138
.onAppear {
139139
viewModel.messages = ChatMessage.samples
140140
}
@@ -143,7 +143,7 @@ struct ConversationScreen_Previews: PreviewProvider {
143143

144144
static var previews: some View {
145145
NavigationStack {
146-
ConversationScreen(firebaseService: FirebaseAI.firebaseAI(), title: "Chat sample")
146+
ConversationScreen(firebaseService: FirebaseAI.firebaseAI())
147147
}
148148
}
149149
}

firebaseai/FirebaseAIExample/ChatExample/ViewModels/ConversationViewModel.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#endif
2020
import Foundation
2121
import UIKit
22+
import GenerativeAIUIComponents
2223

2324
@MainActor
2425
class ConversationViewModel: ObservableObject {
@@ -33,21 +34,37 @@ class ConversationViewModel: ObservableObject {
3334
return error != nil
3435
}
3536

37+
@Published var initialPrompt: String = ""
38+
@Published var title: String = ""
39+
3640
private var model: GenerativeModel
3741
private var chat: Chat
3842
private var stopGenerating = false
3943

4044
private var chatTask: Task<Void, Never>?
4145

42-
init(firebaseService: FirebaseAI, model: GenerativeModel? = nil) {
43-
if let model {
44-
self.model = model
46+
private var sample: Sample?
47+
48+
init(firebaseService: FirebaseAI, sample: Sample? = nil) {
49+
self.sample = sample
50+
51+
// create a generative model with sample data
52+
model = firebaseService.generativeModel(
53+
modelName: "gemini-2.0-flash-001",
54+
tools: sample?.tools,
55+
systemInstruction: sample?.systemInstruction
56+
)
57+
58+
if let chatHistory = sample?.chatHistory, !chatHistory.isEmpty {
59+
// Initialize with sample chat history if it's available
60+
messages = ChatMessage.from(chatHistory)
61+
chat = model.startChat(history: chatHistory)
4562
} else {
46-
self.model = firebaseService.generativeModel(
47-
modelName: "gemini-2.0-flash-001"
48-
)
63+
chat = model.startChat()
4964
}
50-
chat = self.model.startChat()
65+
66+
initialPrompt = sample?.initialPrompt ?? ""
67+
title = sample?.title ?? ""
5168
}
5269

5370
func sendMessage(_ text: String, streaming: Bool = true) async {
@@ -64,6 +81,7 @@ class ConversationViewModel: ObservableObject {
6481
error = nil
6582
chat = model.startChat()
6683
messages.removeAll()
84+
initialPrompt = ""
6785
}
6886

6987
func stop() {

firebaseai/FirebaseAIExample/ContentView.swift

Lines changed: 77 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@
1313
// limitations under the License.
1414

1515
import SwiftUI
16-
#if canImport(FirebaseAILogic)
17-
import FirebaseAILogic
18-
#else
19-
import FirebaseAI
20-
#endif
16+
import FirebaseAI
17+
import GenerativeAIUIComponents
2118

2219
enum BackendOption: String, CaseIterable, Identifiable {
2320
case googleAI = "Gemini Developer API"
@@ -37,73 +34,96 @@ enum BackendOption: String, CaseIterable, Identifiable {
3734
struct ContentView: View {
3835
@State private var selectedBackend: BackendOption = .googleAI
3936
@State private var firebaseService: FirebaseAI = FirebaseAI.firebaseAI(backend: .googleAI())
37+
@State private var selectedUseCase: UseCase = .text
38+
39+
var filteredSamples: [Sample] {
40+
Sample.samples.filter { $0.useCases.contains(selectedUseCase) }
41+
}
42+
43+
let columns = [
44+
GridItem(.adaptive(minimum: 150)),
45+
]
4046

4147
var body: some View {
4248
NavigationStack {
43-
List {
44-
Section("Configuration") {
45-
Picker("Backend", selection: $selectedBackend) {
46-
ForEach(BackendOption.allCases) { option in
47-
Text(option.rawValue).tag(option)
49+
ScrollView {
50+
VStack(alignment: .leading, spacing: 20) {
51+
// Backend Configuration
52+
VStack(alignment: .leading) {
53+
Text("Backend Configuration")
54+
.font(.system(size: 20, weight: .bold))
55+
.padding(.horizontal)
56+
57+
Picker("Backend", selection: $selectedBackend) {
58+
ForEach(BackendOption.allCases) { option in
59+
Text(option.rawValue)
60+
.tag(option)
61+
}
4862
}
63+
.pickerStyle(SegmentedPickerStyle())
64+
.padding(.horizontal)
4965
}
50-
}
5166

52-
Section("Examples") {
53-
NavigationLink {
54-
GenerateContentScreen(firebaseService: firebaseService)
55-
} label: {
56-
Label("Generate Content", systemImage: "doc.text")
57-
}
58-
NavigationLink {
59-
GenerateContentFromTemplateScreen(firebaseService: firebaseService)
60-
} label: {
61-
Label("Generate Content from Template", systemImage: "doc.text.fill")
62-
}
63-
NavigationLink {
64-
PhotoReasoningScreen(firebaseService: firebaseService)
65-
} label: {
66-
Label("Multi-modal", systemImage: "doc.richtext")
67-
}
68-
NavigationLink {
69-
ConversationScreen(firebaseService: firebaseService, title: "Chat")
70-
} label: {
71-
Label("Chat", systemImage: "ellipsis.message.fill")
72-
}
73-
NavigationLink {
74-
ConversationScreen(
75-
firebaseService: firebaseService,
76-
title: "Grounding",
77-
searchGroundingEnabled: true
78-
)
79-
} label: {
80-
Label("Grounding with Google Search", systemImage: "magnifyingglass")
81-
}
82-
NavigationLink {
83-
FunctionCallingScreen(firebaseService: firebaseService)
84-
} label: {
85-
Label("Function Calling", systemImage: "function")
86-
}
87-
NavigationLink {
88-
ImagenScreen(firebaseService: firebaseService)
89-
} label: {
90-
Label("Imagen", systemImage: "camera.circle")
67+
// Use Case Filter
68+
VStack(alignment: .leading) {
69+
Text("Filter by use case")
70+
.font(.system(size: 20, weight: .bold))
71+
.padding(.horizontal)
72+
73+
ScrollView(.horizontal, showsIndicators: false) {
74+
HStack(spacing: 10) {
75+
ForEach(UseCase.allCases) { useCase in
76+
FilterChipView(useCase: useCase, isSelected: selectedUseCase == useCase) {
77+
selectedUseCase = useCase
78+
}
79+
}
80+
}
81+
.padding(.horizontal)
82+
}
9183
}
92-
NavigationLink {
93-
ImagenFromTemplateScreen(firebaseService: firebaseService)
94-
} label: {
95-
Label("Imagen from Template", systemImage: "camera.circle.fill")
84+
85+
// Samples
86+
VStack(alignment: .leading) {
87+
Text("Samples")
88+
.font(.system(size: 20, weight: .bold))
89+
.padding(.horizontal)
90+
91+
LazyVGrid(columns: columns, spacing: 20) {
92+
ForEach(filteredSamples) { sample in
93+
NavigationLink(destination: destinationView(for: sample)) {
94+
SampleCardView(sample: sample)
95+
}
96+
.buttonStyle(PlainButtonStyle())
97+
}
98+
}
99+
.padding(.horizontal)
96100
}
97101
}
102+
.padding(.vertical)
98103
}
99-
.navigationTitle("Generative AI Examples")
104+
.background(Color(.systemGroupedBackground))
105+
.navigationTitle("Firebase AI Logic")
100106
.onChange(of: selectedBackend) { newBackend in
101107
firebaseService = newBackend.backendValue
102-
// Note: This might cause views that hold the old service instance to misbehave
103-
// unless they are also correctly updated or recreated.
104108
}
105109
}
106110
}
111+
112+
@ViewBuilder
113+
private func destinationView(for sample: Sample) -> some View {
114+
switch sample.navRoute {
115+
case "ConversationScreen":
116+
ConversationScreen(firebaseService: firebaseService, sample: sample)
117+
case "ImagenScreen":
118+
ImagenScreen(firebaseService: firebaseService, sample: sample)
119+
case "PhotoReasoningScreen":
120+
PhotoReasoningScreen(firebaseService: firebaseService)
121+
case "FunctionCallingScreen":
122+
FunctionCallingScreen(firebaseService: firebaseService)
123+
default:
124+
EmptyView()
125+
}
126+
}
107127
}
108128

109129
#Preview {

firebaseai/FirebaseAIExample/ImagenExample/ImagenScreen.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ struct ImagenScreen: View {
2424
let firebaseService: FirebaseAI
2525
@StateObject var viewModel: ImagenViewModel
2626

27-
init(firebaseService: FirebaseAI) {
27+
@State
28+
private var userPrompt = ""
29+
30+
init(firebaseService: FirebaseAI, sample: Sample? = nil) {
2831
self.firebaseService = firebaseService
29-
_viewModel = StateObject(wrappedValue: ImagenViewModel(firebaseService: firebaseService))
32+
_viewModel =
33+
StateObject(wrappedValue: ImagenViewModel(firebaseService: firebaseService,
34+
sample: sample))
3035
}
3136

3237
enum FocusedField: Hashable {
@@ -40,7 +45,7 @@ struct ImagenScreen: View {
4045
ZStack {
4146
ScrollView {
4247
VStack {
43-
InputField("Enter a prompt to generate an image", text: $viewModel.userInput) {
48+
InputField("Enter a prompt to generate an image", text: $userPrompt) {
4449
Image(
4550
systemName: viewModel.inProgress ? "stop.circle.fill" : "paperplane.circle.fill"
4651
)
@@ -75,12 +80,15 @@ struct ImagenScreen: View {
7580
.navigationTitle("Imagen example")
7681
.onAppear {
7782
focusedField = .message
83+
if userPrompt.isEmpty && !viewModel.initialPrompt.isEmpty {
84+
userPrompt = viewModel.initialPrompt
85+
}
7886
}
7987
}
8088

8189
private func sendMessage() {
8290
Task {
83-
await viewModel.generateImage(prompt: viewModel.userInput)
91+
await viewModel.generateImage(prompt: userPrompt)
8492
focusedField = .message
8593
}
8694
}

firebaseai/FirebaseAIExample/ImagenExample/ImagenViewModel.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
import Foundation
2121
import OSLog
2222
import SwiftUI
23+
import GenerativeAIUIComponents
2324

2425
@MainActor
2526
class ImagenViewModel: ObservableObject {
2627
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai")
2728

2829
@Published
29-
var userInput: String = ""
30+
var initialPrompt: String = ""
3031

3132
@Published
3233
var images = [UIImage]()
@@ -41,7 +42,11 @@ class ImagenViewModel: ObservableObject {
4142

4243
private var generateImagesTask: Task<Void, Never>?
4344

44-
init(firebaseService: FirebaseAI) {
45+
private var sample: Sample?
46+
47+
init(firebaseService: FirebaseAI, sample: Sample? = nil) {
48+
self.sample = sample
49+
4550
let modelName = "imagen-3.0-generate-002"
4651
let safetySettings = ImagenSafetySettings(
4752
safetyFilterLevel: .blockLowAndAbove
@@ -55,6 +60,8 @@ class ImagenViewModel: ObservableObject {
5560
generationConfig: generationConfig,
5661
safetySettings: safetySettings
5762
)
63+
64+
initialPrompt = sample?.initialPrompt ?? ""
5865
}
5966

6067
func generateImage(prompt: String) async {

0 commit comments

Comments
 (0)