Skip to content

Commit 4599651

Browse files
M1zzclaude
andcommitted
✨ 샘플 프로젝트 & DocC 튜토리얼 최종 완성
- 샘플 프로젝트 README 대폭 개선 (AIChatbot, ARFurniture, BLEScanner, DeliveryTracker, SiriTodo, SubscriptionApp) - 17개 새 샘플 앱 코드 추가 (Shared 모듈 & 메인 앱) - DocC 튜토리얼 목차 완성 (ActivityKit, AppIntents, ARKit, FoundationModels, StoreKit) - 30+ 새 튜토리얼 파일 추가 (고급 기능 & 리소스 코드) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 287d1ea commit 4599651

282 files changed

Lines changed: 39102 additions & 91 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// AIChatbotApp.swift
2+
// AI 채팅봇 앱 진입점
3+
// iOS 26+ | FoundationModels
4+
5+
import SwiftUI
6+
7+
/// AI 채팅봇 앱
8+
/// FoundationModels 프레임워크를 활용한 온디바이스 AI 채팅
9+
@main
10+
struct AIChatbotApp: App {
11+
12+
/// 대화 저장소 (앱 전역 상태)
13+
@State private var conversationStore = ConversationStore()
14+
15+
var body: some Scene {
16+
WindowGroup {
17+
ContentView()
18+
.environment(conversationStore)
19+
}
20+
}
21+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// ContentView.swift
2+
// 메인 채팅 UI
3+
// iOS 26+ | FoundationModels
4+
5+
import SwiftUI
6+
import FoundationModels
7+
8+
/// 메인 채팅 화면
9+
struct ContentView: View {
10+
11+
@Environment(ConversationStore.self) private var store
12+
@State private var showSettings = false
13+
@State private var showUnavailableAlert = false
14+
15+
var body: some View {
16+
NavigationStack {
17+
VStack(spacing: 0) {
18+
// 메시지 목록
19+
messageList
20+
21+
// 구분선
22+
Divider()
23+
24+
// 입력창
25+
InputBarView()
26+
}
27+
.navigationTitle("AI 채팅")
28+
.navigationBarTitleDisplayMode(.inline)
29+
.toolbar {
30+
// 설정 버튼
31+
ToolbarItem(placement: .topBarTrailing) {
32+
Button {
33+
showSettings = true
34+
} label: {
35+
Image(systemName: "gear")
36+
}
37+
}
38+
39+
// 대화 초기화 버튼
40+
ToolbarItem(placement: .topBarLeading) {
41+
Button {
42+
store.clearConversation()
43+
} label: {
44+
Image(systemName: "trash")
45+
}
46+
.disabled(store.messages.isEmpty)
47+
}
48+
}
49+
.sheet(isPresented: $showSettings) {
50+
SettingsView()
51+
}
52+
.alert("모델 사용 불가", isPresented: $showUnavailableAlert) {
53+
Button("확인", role: .cancel) { }
54+
} message: {
55+
Text("이 기기에서는 Apple Intelligence를 사용할 수 없습니다.")
56+
}
57+
.task {
58+
// 앱 시작 시 모델 가용성 확인
59+
await checkModelAvailability()
60+
}
61+
}
62+
}
63+
64+
// MARK: - 메시지 목록
65+
66+
private var messageList: some View {
67+
ScrollViewReader { proxy in
68+
ScrollView {
69+
LazyVStack(spacing: 12) {
70+
// 환영 메시지 (대화가 비어있을 때)
71+
if store.messages.isEmpty {
72+
welcomeMessage
73+
}
74+
75+
// 메시지들
76+
ForEach(store.messages) { message in
77+
MessageBubbleView(message: message)
78+
.id(message.id)
79+
}
80+
81+
// 스트리밍 중인 응답 표시
82+
if store.isGenerating && !store.streamingText.isEmpty {
83+
streamingBubble
84+
}
85+
86+
// 로딩 인디케이터
87+
if store.isGenerating && store.streamingText.isEmpty {
88+
loadingIndicator
89+
}
90+
}
91+
.padding()
92+
}
93+
.onChange(of: store.messages.count) { _, _ in
94+
// 새 메시지가 추가되면 스크롤
95+
scrollToBottom(proxy: proxy)
96+
}
97+
.onChange(of: store.streamingText) { _, _ in
98+
// 스트리밍 중 스크롤
99+
scrollToBottom(proxy: proxy)
100+
}
101+
}
102+
}
103+
104+
// MARK: - 환영 메시지
105+
106+
private var welcomeMessage: some View {
107+
VStack(spacing: 16) {
108+
Image(systemName: "bubble.left.and.bubble.right.fill")
109+
.font(.system(size: 60))
110+
.foregroundStyle(.tint)
111+
112+
Text("AI 채팅봇")
113+
.font(.title2)
114+
.fontWeight(.semibold)
115+
116+
Text("무엇이든 물어보세요!\nApple Intelligence가 답변해드립니다.")
117+
.font(.subheadline)
118+
.foregroundStyle(.secondary)
119+
.multilineTextAlignment(.center)
120+
}
121+
.padding(.vertical, 60)
122+
}
123+
124+
// MARK: - 스트리밍 버블
125+
126+
private var streamingBubble: some View {
127+
HStack {
128+
VStack(alignment: .leading, spacing: 4) {
129+
Text(store.streamingText)
130+
.textSelection(.enabled)
131+
}
132+
.padding(12)
133+
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16))
134+
135+
Spacer(minLength: 60)
136+
}
137+
}
138+
139+
// MARK: - 로딩 인디케이터
140+
141+
private var loadingIndicator: some View {
142+
HStack {
143+
HStack(spacing: 4) {
144+
ForEach(0..<3) { index in
145+
Circle()
146+
.fill(.secondary)
147+
.frame(width: 8, height: 8)
148+
.scaleEffect(1.0)
149+
.animation(
150+
.easeInOut(duration: 0.6)
151+
.repeatForever()
152+
.delay(Double(index) * 0.2),
153+
value: store.isGenerating
154+
)
155+
}
156+
}
157+
.padding(12)
158+
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16))
159+
160+
Spacer()
161+
}
162+
}
163+
164+
// MARK: - 헬퍼
165+
166+
private func scrollToBottom(proxy: ScrollViewProxy) {
167+
if let lastMessage = store.messages.last {
168+
withAnimation(.easeOut(duration: 0.2)) {
169+
proxy.scrollTo(lastMessage.id, anchor: .bottom)
170+
}
171+
}
172+
}
173+
174+
private func checkModelAvailability() async {
175+
let availability = await ChatManager.checkAvailability()
176+
177+
switch availability {
178+
case .available:
179+
break // 사용 가능
180+
case .unavailable:
181+
showUnavailableAlert = true
182+
@unknown default:
183+
break
184+
}
185+
}
186+
}
187+
188+
// MARK: - 프리뷰
189+
190+
#Preview {
191+
ContentView()
192+
.environment(ConversationStore.preview)
193+
}
194+
195+
#Preview("Empty") {
196+
ContentView()
197+
.environment(ConversationStore())
198+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// InputBarView.swift
2+
// 채팅 입력창
3+
// iOS 26+ | FoundationModels
4+
5+
import SwiftUI
6+
7+
/// 메시지 입력 바
8+
struct InputBarView: View {
9+
10+
@Environment(ConversationStore.self) private var store
11+
@State private var inputText: String = ""
12+
@FocusState private var isFocused: Bool
13+
14+
/// 전송 가능 여부
15+
private var canSend: Bool {
16+
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
17+
&& !store.isGenerating
18+
}
19+
20+
var body: some View {
21+
HStack(alignment: .bottom, spacing: 12) {
22+
// 텍스트 입력 필드
23+
textField
24+
25+
// 전송 버튼
26+
sendButton
27+
}
28+
.padding(.horizontal)
29+
.padding(.vertical, 8)
30+
.background(.bar)
31+
}
32+
33+
// MARK: - 텍스트 필드
34+
35+
private var textField: some View {
36+
TextField("메시지를 입력하세요...", text: $inputText, axis: .vertical)
37+
.textFieldStyle(.plain)
38+
.padding(.horizontal, 12)
39+
.padding(.vertical, 8)
40+
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 20))
41+
.lineLimit(1...5)
42+
.focused($isFocused)
43+
.submitLabel(.send)
44+
.onSubmit {
45+
sendMessage()
46+
}
47+
}
48+
49+
// MARK: - 전송 버튼
50+
51+
private var sendButton: some View {
52+
Button {
53+
sendMessage()
54+
} label: {
55+
Image(systemName: store.isGenerating ? "stop.fill" : "arrow.up.circle.fill")
56+
.font(.title)
57+
.symbolRenderingMode(.hierarchical)
58+
.foregroundStyle(buttonColor)
59+
}
60+
.disabled(!canSend && !store.isGenerating)
61+
.animation(.easeInOut(duration: 0.2), value: store.isGenerating)
62+
}
63+
64+
/// 버튼 색상
65+
private var buttonColor: Color {
66+
if store.isGenerating {
67+
return .red
68+
} else if canSend {
69+
return .accentColor
70+
} else {
71+
return .secondary
72+
}
73+
}
74+
75+
// MARK: - 액션
76+
77+
private func sendMessage() {
78+
// 생성 중이면 취소
79+
if store.isGenerating {
80+
store.chatManager.cancel()
81+
return
82+
}
83+
84+
// 빈 메시지 무시
85+
let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
86+
guard !trimmed.isEmpty else { return }
87+
88+
// 입력 초기화
89+
let message = trimmed
90+
inputText = ""
91+
92+
// 메시지 전송
93+
Task {
94+
await store.send(message)
95+
}
96+
}
97+
}
98+
99+
// MARK: - 프리뷰
100+
101+
#Preview {
102+
VStack {
103+
Spacer()
104+
InputBarView()
105+
}
106+
.environment(ConversationStore())
107+
}
108+
109+
#Preview("With Text") {
110+
VStack {
111+
Spacer()
112+
InputBarView()
113+
}
114+
.environment(ConversationStore())
115+
}

0 commit comments

Comments
 (0)