@ondeinference / klepon / commits / 1a36a5f

Initial commit: Klepon iOS app with local guide and private AI

Seto Elkahfi committed May 14, 2026 at 15:08 UTC 1a36a5fd9f47a2aa6a68440578b431e6c11ec479
54 files changed +4136
.gitignore new
+89
@@ -0,0 +1,89 @@
1 +# macOS
2 +.DS_Store
3 +.AppleDouble
4 +.LSOverride
5 +._*
6 +
7 +# Editors
8 +.vscode/
9 +.idea/
10 +*.swp
11 +*.swo
12 +*~
13 +
14 +# Xcode user state
15 +*.pbxuser
16 +!default.pbxuser
17 +*.mode1v3
18 +!default.mode1v3
19 +*.mode2v3
20 +!default.mode2v3
21 +*.perspectivev3
22 +!default.perspectivev3
23 +*.xcuserstate
24 +xcuserdata/
25 +*.xcworkspace/xcuserdata/
26 +project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
27 +
28 +# Xcode build artifacts
29 +build/
30 +DerivedData/
31 +*.moved-aside
32 +*.xccheckout
33 +*.xcscmblueprint
34 +*.xcresult
35 +*.hmap
36 +
37 +# Swift Package Manager
38 +.build/
39 +.swiftpm/
40 +Packages/
41 +# Keep Package.resolved committed for reproducible app builds.
42 +
43 +# CocoaPods / Carthage (if ever used)
44 +Pods/
45 +Carthage/Build/
46 +Carthage/Checkouts/
47 +
48 +# Python utility scripts
49 +__pycache__/
50 +*.pyc
51 +.venv/
52 +venv/
53 +
54 +# Local environment and secrets
55 +.env
56 +.env.*
57 +*.local
58 +*.xcconfig.local
59 +*.secret
60 +Secrets/
61 +secrets/
62 +
63 +# Signing, keys, and credentials
64 +*.mobileprovision
65 +*.provisionprofile
66 +*.p8
67 +*.p12
68 +*.pem
69 +*.key
70 +*.cer
71 +*.crt
72 +*.csr
73 +AuthKey_*.p8
74 +
75 +# App packaging / archives
76 +*.xcarchive
77 +*.ipa
78 +*.dSYM
79 +*.dSYM.zip
80 +
81 +# Crash reporting
82 +crashlytics-build.properties
83 +crashlytics.properties
84 +fabric.properties
85 +
86 +# Code Injection / Playgrounds
87 +iOSInjectionProject/
88 +timeline.xctimeline
89 +playground.xcworkspace
Klepon.xcodeproj/project.pbxproj new
+364
@@ -0,0 +1,364 @@
1 +// !$*UTF8*$!
2 +{
3 + archiveVersion = 1;
4 + classes = {
5 + };
6 + objectVersion = 77;
7 + objects = {
8 +
9 +/* Begin PBXBuildFile section */
10 + 8AA60E2D2F56094200531855 /* Onde in Frameworks */ = {isa = PBXBuildFile; productRef = 8AA60E2C2F56094200531855 /* Onde */; };
11 +/* End PBXBuildFile section */
12 +
13 +/* Begin PBXFileReference section */
14 + 8AA60E1D2F56093100531855 /* Klepon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Klepon.app; sourceTree = BUILT_PRODUCTS_DIR; };
15 +/* End PBXFileReference section */
16 +
17 +/* Begin PBXFileSystemSynchronizedRootGroup section */
18 + 8AA60E1F2F56093100531855 /* Klepon */ = {
19 + isa = PBXFileSystemSynchronizedRootGroup;
20 + path = Klepon;
21 + sourceTree = "<group>";
22 + };
23 +/* End PBXFileSystemSynchronizedRootGroup section */
24 +
25 +/* Begin PBXFrameworksBuildPhase section */
26 + 8AA60E1A2F56093100531855 /* Frameworks */ = {
27 + isa = PBXFrameworksBuildPhase;
28 + buildActionMask = 2147483647;
29 + files = (
30 + 8AA60E2D2F56094200531855 /* Onde in Frameworks */,
31 + );
32 + runOnlyForDeploymentPostprocessing = 0;
33 + };
34 +/* End PBXFrameworksBuildPhase section */
35 +
36 +/* Begin PBXGroup section */
37 + 8AA60E142F56093100531855 = {
38 + isa = PBXGroup;
39 + children = (
40 + 8AA60E1F2F56093100531855 /* Klepon */,
41 + 8AA60E1E2F56093100531855 /* Products */,
42 + );
43 + sourceTree = "<group>";
44 + };
45 + 8AA60E1E2F56093100531855 /* Products */ = {
46 + isa = PBXGroup;
47 + children = (
48 + 8AA60E1D2F56093100531855 /* Klepon.app */,
49 + );
50 + name = Products;
51 + sourceTree = "<group>";
52 + };
53 +/* End PBXGroup section */
54 +
55 +/* Begin PBXNativeTarget section */
56 + 8AA60E1C2F56093100531855 /* Klepon */ = {
57 + isa = PBXNativeTarget;
58 + buildConfigurationList = 8AA60E282F56093200531855 /* Build configuration list for PBXNativeTarget "Klepon" */;
59 + buildPhases = (
60 + 8AA60E192F56093100531855 /* Sources */,
61 + 8AA60E1A2F56093100531855 /* Frameworks */,
62 + 8AA60E1B2F56093100531855 /* Resources */,
63 + );
64 + buildRules = (
65 + );
66 + dependencies = (
67 + );
68 + fileSystemSynchronizedGroups = (
69 + 8AA60E1F2F56093100531855 /* Klepon */,
70 + );
71 + name = Klepon;
72 + packageProductDependencies = (
73 + 8AA60E2C2F56094200531855 /* Onde */,
74 + );
75 + productName = Klepon;
76 + productReference = 8AA60E1D2F56093100531855 /* Klepon.app */;
77 + productType = "com.apple.product-type.application";
78 + };
79 +/* End PBXNativeTarget section */
80 +
81 +/* Begin PBXProject section */
82 + 8AA60E152F56093100531855 /* Project object */ = {
83 + isa = PBXProject;
84 + attributes = {
85 + BuildIndependentTargetsInParallel = 1;
86 + LastSwiftUpdateCheck = 2620;
87 + LastUpgradeCheck = 2620;
88 + TargetAttributes = {
89 + 8AA60E1C2F56093100531855 = {
90 + CreatedOnToolsVersion = 26.2;
91 + };
92 + };
93 + };
94 + buildConfigurationList = 8AA60E182F56093100531855 /* Build configuration list for PBXProject "Klepon" */;
95 + developmentRegion = en;
96 + hasScannedForEncodings = 0;
97 + knownRegions = (
98 + en,
99 + Base,
100 + );
101 + mainGroup = 8AA60E142F56093100531855;
102 + minimizedProjectReferenceProxies = 1;
103 + packageReferences = (
104 + 8AB50B362F82BD7700CE1848 /* XCRemoteSwiftPackageReference "onde-swift" */,
105 + );
106 + preferredProjectObjectVersion = 77;
107 + productRefGroup = 8AA60E1E2F56093100531855 /* Products */;
108 + projectDirPath = "";
109 + projectRoot = "";
110 + targets = (
111 + 8AA60E1C2F56093100531855 /* Klepon */,
112 + );
113 + };
114 +/* End PBXProject section */
115 +
116 +/* Begin PBXResourcesBuildPhase section */
117 + 8AA60E1B2F56093100531855 /* Resources */ = {
118 + isa = PBXResourcesBuildPhase;
119 + buildActionMask = 2147483647;
120 + files = (
121 + );
122 + runOnlyForDeploymentPostprocessing = 0;
123 + };
124 +/* End PBXResourcesBuildPhase section */
125 +
126 +/* Begin PBXSourcesBuildPhase section */
127 + 8AA60E192F56093100531855 /* Sources */ = {
128 + isa = PBXSourcesBuildPhase;
129 + buildActionMask = 2147483647;
130 + files = (
131 + );
132 + runOnlyForDeploymentPostprocessing = 0;
133 + };
134 +/* End PBXSourcesBuildPhase section */
135 +
136 +/* Begin XCBuildConfiguration section */
137 + 8AA60E262F56093200531855 /* Debug */ = {
138 + isa = XCBuildConfiguration;
139 + buildSettings = {
140 + ALWAYS_SEARCH_USER_PATHS = NO;
141 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
142 + CLANG_ANALYZER_NONNULL = YES;
143 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
144 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
145 + CLANG_ENABLE_MODULES = YES;
146 + CLANG_ENABLE_OBJC_ARC = YES;
147 + CLANG_ENABLE_OBJC_WEAK = YES;
148 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
149 + CLANG_WARN_BOOL_CONVERSION = YES;
150 + CLANG_WARN_COMMA = YES;
151 + CLANG_WARN_CONSTANT_CONVERSION = YES;
152 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
153 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
154 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
155 + CLANG_WARN_EMPTY_BODY = YES;
156 + CLANG_WARN_ENUM_CONVERSION = YES;
157 + CLANG_WARN_INFINITE_RECURSION = YES;
158 + CLANG_WARN_INT_CONVERSION = YES;
159 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
160 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
161 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
162 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
163 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
164 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
165 + CLANG_WARN_STRICT_PROTOTYPES = YES;
166 + CLANG_WARN_SUSPICIOUS_MOVE = YES;
167 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
168 + CLANG_WARN_UNREACHABLE_CODE = YES;
169 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
170 + COPY_PHASE_STRIP = NO;
171 + DEBUG_INFORMATION_FORMAT = dwarf;
172 + DEVELOPMENT_TEAM = "";
173 + ENABLE_STRICT_OBJC_MSGSEND = YES;
174 + ENABLE_TESTABILITY = YES;
175 + ENABLE_USER_SCRIPT_SANDBOXING = YES;
176 + GCC_C_LANGUAGE_STANDARD = gnu17;
177 + GCC_DYNAMIC_NO_PIC = NO;
178 + GCC_NO_COMMON_BLOCKS = YES;
179 + GCC_OPTIMIZATION_LEVEL = 0;
180 + GCC_PREPROCESSOR_DEFINITIONS = (
181 + "DEBUG=1",
182 + "$(inherited)",
183 + );
184 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
185 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
186 + GCC_WARN_UNDECLARED_SELECTOR = YES;
187 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
188 + GCC_WARN_UNUSED_FUNCTION = YES;
189 + GCC_WARN_UNUSED_VARIABLE = YES;
190 + IPHONEOS_DEPLOYMENT_TARGET = 18.5;
191 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
192 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
193 + MTL_FAST_MATH = YES;
194 + ONLY_ACTIVE_ARCH = YES;
195 + SDKROOT = iphoneos;
196 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
197 + SWIFT_OPTIMIZATION_LEVEL = "-Onone";
198 + };
199 + name = Debug;
200 + };
201 + 8AA60E272F56093200531855 /* Release */ = {
202 + isa = XCBuildConfiguration;
203 + buildSettings = {
204 + ALWAYS_SEARCH_USER_PATHS = NO;
205 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
206 + CLANG_ANALYZER_NONNULL = YES;
207 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
208 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
209 + CLANG_ENABLE_MODULES = YES;
210 + CLANG_ENABLE_OBJC_ARC = YES;
211 + CLANG_ENABLE_OBJC_WEAK = YES;
212 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
213 + CLANG_WARN_BOOL_CONVERSION = YES;
214 + CLANG_WARN_COMMA = YES;
215 + CLANG_WARN_CONSTANT_CONVERSION = YES;
216 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
217 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
218 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
219 + CLANG_WARN_EMPTY_BODY = YES;
220 + CLANG_WARN_ENUM_CONVERSION = YES;
221 + CLANG_WARN_INFINITE_RECURSION = YES;
222 + CLANG_WARN_INT_CONVERSION = YES;
223 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
224 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
225 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
226 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
227 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
228 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
229 + CLANG_WARN_STRICT_PROTOTYPES = YES;
230 + CLANG_WARN_SUSPICIOUS_MOVE = YES;
231 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
232 + CLANG_WARN_UNREACHABLE_CODE = YES;
233 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
234 + COPY_PHASE_STRIP = NO;
235 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
236 + DEVELOPMENT_TEAM = "";
237 + ENABLE_NS_ASSERTIONS = NO;
238 + ENABLE_STRICT_OBJC_MSGSEND = YES;
239 + ENABLE_USER_SCRIPT_SANDBOXING = YES;
240 + GCC_C_LANGUAGE_STANDARD = gnu17;
241 + GCC_NO_COMMON_BLOCKS = YES;
242 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
243 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
244 + GCC_WARN_UNDECLARED_SELECTOR = YES;
245 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
246 + GCC_WARN_UNUSED_FUNCTION = YES;
247 + GCC_WARN_UNUSED_VARIABLE = YES;
248 + IPHONEOS_DEPLOYMENT_TARGET = 18.5;
249 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
250 + MTL_ENABLE_DEBUG_INFO = NO;
251 + MTL_FAST_MATH = YES;
252 + SDKROOT = iphoneos;
253 + SWIFT_COMPILATION_MODE = wholemodule;
254 + VALIDATE_PRODUCT = YES;
255 + };
256 + name = Release;
257 + };
258 + 8AA60E292F56093200531855 /* Debug */ = {
259 + isa = XCBuildConfiguration;
260 + buildSettings = {
261 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
262 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
263 + CODE_SIGN_ENTITLEMENTS = Klepon/Klepon.entitlements;
264 + CODE_SIGN_STYLE = Automatic;
265 + CURRENT_PROJECT_VERSION = 1;
266 + DEVELOPMENT_TEAM = "";
267 + ENABLE_PREVIEWS = YES;
268 + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
269 + GENERATE_INFOPLIST_FILE = YES;
270 + INFOPLIST_KEY_UILaunchScreen_Generation = YES;
271 + INFOPLIST_KEY_UIUserInterfaceStyle = Light;
272 + IPHONEOS_DEPLOYMENT_TARGET = 18.5;
273 + LD_RUNPATH_SEARCH_PATHS = (
274 + "$(inherited)",
275 + "@executable_path/Frameworks",
276 + );
277 + MARKETING_VERSION = 1.0;
278 + PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
279 + PRODUCT_NAME = Klepon;
280 + STRING_CATALOG_GENERATE_SYMBOLS = YES;
281 + SWIFT_APPROACHABLE_CONCURRENCY = YES;
282 + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
283 + SWIFT_EMIT_LOC_STRINGS = YES;
284 + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
285 + SWIFT_VERSION = 5.0;
286 + TARGETED_DEVICE_FAMILY = 1;
287 + };
288 + name = Debug;
289 + };
290 + 8AA60E2A2F56093200531855 /* Release */ = {
291 + isa = XCBuildConfiguration;
292 + buildSettings = {
293 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
294 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
295 + CODE_SIGN_ENTITLEMENTS = Klepon/Klepon.entitlements;
296 + CODE_SIGN_STYLE = Automatic;
297 + CURRENT_PROJECT_VERSION = 1;
298 + DEVELOPMENT_TEAM = "";
299 + ENABLE_PREVIEWS = YES;
300 + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
301 + GENERATE_INFOPLIST_FILE = YES;
302 + INFOPLIST_KEY_UILaunchScreen_Generation = YES;
303 + INFOPLIST_KEY_UIUserInterfaceStyle = Light;
304 + IPHONEOS_DEPLOYMENT_TARGET = 18.5;
305 + LD_RUNPATH_SEARCH_PATHS = (
306 + "$(inherited)",
307 + "@executable_path/Frameworks",
308 + );
309 + MARKETING_VERSION = 1.0;
310 + PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
311 + PRODUCT_NAME = Klepon;
312 + STRING_CATALOG_GENERATE_SYMBOLS = YES;
313 + SWIFT_APPROACHABLE_CONCURRENCY = YES;
314 + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
315 + SWIFT_EMIT_LOC_STRINGS = YES;
316 + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
317 + SWIFT_VERSION = 5.0;
318 + TARGETED_DEVICE_FAMILY = 1;
319 + };
320 + name = Release;
321 + };
322 +/* End XCBuildConfiguration section */
323 +
324 +/* Begin XCConfigurationList section */
325 + 8AA60E182F56093100531855 /* Build configuration list for PBXProject "Klepon" */ = {
326 + isa = XCConfigurationList;
327 + buildConfigurations = (
328 + 8AA60E262F56093200531855 /* Debug */,
329 + 8AA60E272F56093200531855 /* Release */,
330 + );
331 + defaultConfigurationIsVisible = 0;
332 + defaultConfigurationName = Release;
333 + };
334 + 8AA60E282F56093200531855 /* Build configuration list for PBXNativeTarget "Klepon" */ = {
335 + isa = XCConfigurationList;
336 + buildConfigurations = (
337 + 8AA60E292F56093200531855 /* Debug */,
338 + 8AA60E2A2F56093200531855 /* Release */,
339 + );
340 + defaultConfigurationIsVisible = 0;
341 + defaultConfigurationName = Release;
342 + };
343 +/* End XCConfigurationList section */
344 +
345 +/* Begin XCRemoteSwiftPackageReference section */
346 + 8AB50B362F82BD7700CE1848 /* XCRemoteSwiftPackageReference "onde-swift" */ = {
347 + isa = XCRemoteSwiftPackageReference;
348 + repositoryURL = "https://github.com/ondeinference/onde-swift";
349 + requirement = {
350 + kind = exactVersion;
351 + version = 0.1.0;
352 + };
353 + };
354 +/* End XCRemoteSwiftPackageReference section */
355 +
356 +/* Begin XCSwiftPackageProductDependency section */
357 + 8AA60E2C2F56094200531855 /* Onde */ = {
358 + isa = XCSwiftPackageProductDependency;
359 + productName = Onde;
360 + };
361 +/* End XCSwiftPackageProductDependency section */
362 + };
363 + rootObject = 8AA60E152F56093100531855 /* Project object */;
364 +}
Klepon.xcodeproj/project.xcworkspace/contents.xcworkspacedata new
+7
@@ -0,0 +1,7 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<Workspace
3 + version = "1.0">
4 + <FileRef
5 + location = "self:">
6 + </FileRef>
7 +</Workspace>
Klepon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new
+15
@@ -0,0 +1,15 @@
1 +{
2 + "originHash" : "c082d58172ce2493986244137cece2181a09f6e7ac51fc87cffba6270d7d243a",
3 + "pins" : [
4 + {
5 + "identity" : "onde-swift",
6 + "kind" : "remoteSourceControl",
7 + "location" : "https://github.com/ondeinference/onde-swift",
8 + "state" : {
9 + "revision" : "c3114996521c9aee2aa0bc71b7324a8713376103",
10 + "version" : "0.1.0"
11 + }
12 + }
13 + ],
14 + "version" : 3
15 +}
Klepon/AI/GuideAnswerService.swift new
+151
@@ -0,0 +1,151 @@
1 +import Foundation
2 +
3 +final class GuideAnswerService {
4 + private let repository: ContentRepository
5 + private let guideEngine: OndeGuideEngine
6 +
7 + init(repository: ContentRepository, guideEngine: OndeGuideEngine) {
8 + self.repository = repository
9 + self.guideEngine = guideEngine
10 + }
11 +
12 + func answer(question: String, about entry: GuideEntry, previousCards: [AnswerCard] = []) async
13 + -> AnswerCard
14 + {
15 + let contextEntries = groundingEntries(for: entry)
16 + let prompt = buildPrompt(
17 + question: question, using: contextEntries, previousCards: previousCards)
18 +
19 + do {
20 + let rawBody = try await guideEngine.answer(prompt: prompt)
21 + let body = normalizedAnswerBody(rawBody, for: entry, using: contextEntries)
22 + return AnswerCard(
23 + question: question,
24 + headline: "A private answer about \(entry.title)",
25 + body: body,
26 + highlights: Array(entry.tasteNotes.prefix(3)),
27 + followUpSuggestions: followUpSuggestions(for: entry, excluding: question),
28 + sourceEntryIDs: contextEntries.map(\.id),
29 + isGeneratedOnDevice: true
30 + )
31 + } catch {
32 + return fallbackAnswer(question: question, about: entry, using: contextEntries)
33 + }
34 + }
35 +
36 + private func groundingEntries(for entry: GuideEntry) -> [GuideEntry] {
37 + let related = repository.relatedEntries(for: entry)
38 + return Array(([entry] + related).prefix(3))
39 + }
40 +
41 + private func buildPrompt(
42 + question: String, using contextEntries: [GuideEntry], previousCards: [AnswerCard]
43 + ) -> String {
44 + let notes = contextEntries.map { entry in
45 + """
46 + Title: \(entry.title)
47 + Type: \(entry.kind.displayTitle)
48 + Summary: \(entry.summary)
49 + Story: \(entry.story)
50 + Taste notes: \(entry.tasteNotes.joined(separator: ", "))
51 + Highlights: \(entry.highlights.joined(separator: "; "))
52 + Region: \(entry.region ?? "Not specified")
53 + """
54 + }
55 + .joined(separator: "\n\n")
56 +
57 + let previousContext = previousConversationContext(from: previousCards)
58 +
59 + return """
60 + You are answering inside Klepon, a private iPhone guide to Indonesian food.
61 + Use only the guide notes below.
62 + If the notes are not enough, say the guide does not have enough detail yet.
63 + Keep the tone warm, respectful, and concise.
64 + Answer in three short parts:
65 + 1. Short answer
66 + 2. What to expect
67 + 3. Try next
68 +
69 + Guide notes:
70 + \(notes)
71 +
72 + \(previousContext)
73 + User question: \(question)
74 + """
75 + }
76 +
77 + private func previousConversationContext(from previousCards: [AnswerCard]) -> String {
78 + let recentCards = previousCards.suffix(2)
79 + guard !recentCards.isEmpty else { return "" }
80 +
81 + let formatted = recentCards.map { card in
82 + "Question: \(card.question)\nAnswer: \(card.body)"
83 + }
84 + .joined(separator: "\n\n")
85 +
86 + return "Previous guide conversation:\n\(formatted)\n"
87 + }
88 +
89 + private func followUpSuggestions(for entry: GuideEntry, excluding question: String) -> [String]
90 + {
91 + let normalizedQuestion = question.lowercased()
92 + let filtered = entry.suggestedQuestions.filter { $0.lowercased() != normalizedQuestion }
93 + return Array(filtered.prefix(3))
94 + }
95 +
96 + private func normalizedAnswerBody(
97 + _ rawBody: String,
98 + for entry: GuideEntry,
99 + using contextEntries: [GuideEntry]
100 + ) -> String {
101 + let trimmedBody = rawBody.trimmingCharacters(in: .whitespacesAndNewlines)
102 + let lowercasedBody = trimmedBody.lowercased()
103 +
104 + if lowercasedBody.contains("short answer") || lowercasedBody.contains("what to expect")
105 + || lowercasedBody.contains("try next")
106 + {
107 + return trimmedBody
108 + }
109 +
110 + let tryNext = contextEntries.dropFirst().first?.title ?? entry.title
111 + let expectation = expectationLine(for: entry)
112 +
113 + return """
114 + Short answer: \(trimmedBody)
115 + What to expect: \(expectation)
116 + Try next: If you want a nearby path through the guide, open \(tryNext) next.
117 + """
118 + }
119 +
120 + private func expectationLine(for entry: GuideEntry) -> String {
121 + let taste = entry.tasteNotes.prefix(3).joined(separator: ", ")
122 + if taste.isEmpty {
123 + return entry.summary
124 + }
125 + return "Expect \(taste), with the overall dish feeling \(entry.summary.lowercased())."
126 + }
127 +
128 + private func fallbackAnswer(
129 + question: String, about entry: GuideEntry, using contextEntries: [GuideEntry]
130 + ) -> AnswerCard {
131 + let regionLine =
132 + entry.region.map { "It is often associated with \($0)." }
133 + ?? "Regional context can vary."
134 + let tryNext = contextEntries.dropFirst().first?.title ?? entry.title
135 + let body = """
136 + Short answer: \(entry.title) is \(entry.summary.lowercased())
137 + What to expect: \(entry.story) \(regionLine)
138 + Try next: Once the private guide is ready, ask a follow-up or open \(tryNext) next for a nearby reference point.
139 + """
140 +
141 + return AnswerCard(
142 + question: question,
143 + headline: "A grounded guide note for now",
144 + body: body,
145 + highlights: Array(entry.highlights.prefix(3)),
146 + followUpSuggestions: followUpSuggestions(for: entry, excluding: question),
147 + sourceEntryIDs: contextEntries.map(\.id),
148 + isGeneratedOnDevice: false
149 + )
150 + }
151 +}
Klepon/AI/OndeEnvironmentBootstrap.swift new
+109
@@ -0,0 +1,109 @@
1 +import Foundation
2 +
3 +enum OndeEnvironmentBootstrap {
4 + struct Paths {
5 + let baseURL: URL
6 + let hfHomeURL: URL
7 + let hfHubCacheURL: URL
8 + let temporaryURL: URL
9 + }
10 +
11 + private static var didConfigure = false
12 +
13 + static var estimatedDownloadDescription: String {
14 + "About 941 MB"
15 + }
16 +
17 + static func configureIfNeeded() {
18 + guard !didConfigure, let paths = paths() else { return }
19 +
20 + [paths.baseURL, paths.hfHomeURL, paths.hfHubCacheURL, paths.temporaryURL].forEach {
21 + try? FileManager.default.createDirectory(
22 + at: $0,
23 + withIntermediateDirectories: true,
24 + attributes: nil
25 + )
26 + }
27 +
28 + setenv("HF_HOME", paths.hfHomeURL.path, 1)
29 + setenv("HF_HUB_CACHE", paths.hfHubCacheURL.path, 1)
30 + setenv("TMPDIR", paths.temporaryURL.path, 1)
31 + didConfigure = true
32 + }
33 +
34 + static func storageUsageDescription() -> String {
35 + guard let paths = paths() else { return "Not available" }
36 + let byteCount = directorySize(at: paths.baseURL)
37 + guard byteCount > 0 else { return "Not downloaded yet" }
38 + return ByteCountFormatter.string(fromByteCount: byteCount, countStyle: .file)
39 + }
40 +
41 + static func clearPrivateGuideFiles() {
42 + guard let paths = paths() else { return }
43 + let fileManager = FileManager.default
44 + try? fileManager.removeItem(at: paths.baseURL)
45 + try? fileManager.removeItem(at: paths.temporaryURL)
46 + didConfigure = false
47 + configureIfNeeded()
48 + }
49 +
50 + static func paths() -> Paths? {
51 + let fileManager = FileManager.default
52 +
53 + let baseURL: URL
54 + if let sharedContainer = fileManager.containerURL(
55 + forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps"
56 + ) {
57 + baseURL = sharedContainer
58 + } else if let appSupportURL = fileManager.urls(
59 + for: .applicationSupportDirectory,
60 + in: .userDomainMask
61 + ).first {
62 + baseURL = appSupportURL
63 + } else {
64 + return nil
65 + }
66 +
67 + let hfHomeURL = baseURL.appendingPathComponent("models", isDirectory: true)
68 + let hfHubCacheURL = hfHomeURL.appendingPathComponent("hub", isDirectory: true)
69 + let temporaryURL = baseURL.appendingPathComponent("tmp", isDirectory: true)
70 +
71 + return Paths(
72 + baseURL: baseURL,
73 + hfHomeURL: hfHomeURL,
74 + hfHubCacheURL: hfHubCacheURL,
75 + temporaryURL: temporaryURL
76 + )
77 + }
78 +
79 + private static func directorySize(at url: URL) -> Int64 {
80 + let fileManager = FileManager.default
81 + guard
82 + let enumerator = fileManager.enumerator(
83 + at: url,
84 + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
85 + options: [.skipsHiddenFiles]
86 + )
87 + else {
88 + return 0
89 + }
90 +
91 + var totalSize: Int64 = 0
92 +
93 + for case let fileURL as URL in enumerator {
94 + guard
95 + let resourceValues = try? fileURL.resourceValues(forKeys: [
96 + .isRegularFileKey, .fileSizeKey,
97 + ]),
98 + resourceValues.isRegularFile == true,
99 + let fileSize = resourceValues.fileSize
100 + else {
101 + continue
102 + }
103 +
104 + totalSize += Int64(fileSize)
105 + }
106 +
107 + return totalSize
108 + }
109 +}
Klepon/AI/OndeGuideEngine.swift new
+182
@@ -0,0 +1,182 @@
1 +import Combine
2 +import Foundation
3 +import Onde
4 +
5 +enum PrivateGuideAvailability: Equatable {
6 + case notInstalled
7 + case downloaded
8 + case preparing
9 + case ready
10 + case answering
11 + case failed(String)
12 +
13 + var title: String {
14 + switch self {
15 + case .notInstalled:
16 + return "Private guide not ready yet"
17 + case .downloaded:
18 + return "Private guide downloaded on this iPhone"
19 + case .preparing:
20 + return "Preparing your private guide"
21 + case .ready:
22 + return "Private guide ready on this iPhone"
23 + case .answering:
24 + return "Answering privately"
25 + case .failed:
26 + return "Private guide needs attention"
27 + }
28 + }
29 +
30 + var detail: String {
31 + switch self {
32 + case .notInstalled:
33 + return
34 + "You can browse everything first, then add a one-time private guide download whenever you want deeper follow-up answers."
35 + case .downloaded:
36 + return
37 + "The private guide is already stored on this iPhone. Finish preparing it when you want faster follow-up answers in the app."
38 + case .preparing:
39 + return
40 + "The first run can take a while because Klepon needs to download and load the private guide locally on your iPhone."
41 + case .ready:
42 + return
43 + "Klepon can now answer follow-up questions privately without turning the app into a generic chat shell."
44 + case .answering:
45 + return
46 + "Klepon is grounding your question against the local guide and composing a concise answer."
47 + case .failed(let message):
48 + return message
49 + }
50 + }
51 +
52 + var actionTitle: String {
53 + switch self {
54 + case .ready:
55 + return "Private guide ready"
56 + case .failed:
57 + return "Try again"
58 + case .downloaded:
59 + return "Finish preparing private guide"
60 + default:
61 + return "Prepare private guide"
62 + }
63 + }
64 +
65 + var isBusy: Bool {
66 + switch self {
67 + case .preparing, .answering:
68 + return true
69 + default:
70 + return false
71 + }
72 + }
73 +
74 + var isFailure: Bool {
75 + if case .failed = self {
76 + return true
77 + }
78 + return false
79 + }
80 +}
81 +
82 +enum OndeGuideError: LocalizedError {
83 + case unavailable
84 +
85 + var errorDescription: String? {
86 + switch self {
87 + case .unavailable:
88 + return "The private guide is not ready yet."
89 + }
90 + }
91 +}
92 +
93 +@MainActor
94 +final class OndeGuideEngine: ObservableObject {
95 + @Published private(set) var availability: PrivateGuideAvailability = .notInstalled
96 + @Published private(set) var storageUsedDescription: String =
97 + OndeEnvironmentBootstrap.storageUsageDescription()
98 +
99 + private let engine = OndeChatEngine()
100 +
101 + var estimatedDownloadDescription: String {
102 + OndeEnvironmentBootstrap.estimatedDownloadDescription
103 + }
104 +
105 + func prepareIfNeeded(forceReload: Bool = false) async {
106 + if forceReload {
107 + _ = await engine.unloadModel()
108 + availability = .notInstalled
109 + }
110 +
111 + switch availability {
112 + case .ready, .preparing, .answering:
113 + return
114 + case .notInstalled, .downloaded, .failed:
115 + break
116 + }
117 +
118 + availability = .preparing
119 + OndeEnvironmentBootstrap.configureIfNeeded()
120 +
121 + do {
122 + _ = try await engine.loadGgufModel(
123 + config: qwen2515bConfig(),
124 + systemPrompt:
125 + "You are Klepon, a warm and careful guide to Indonesian food. Stay grounded in the notes you are given, keep answers short, and say clearly when the guide does not have enough detail.",
126 + sampling: nil
127 + )
128 + availability = .ready
129 + refreshStorageUsage()
130 + } catch {
131 + availability = .failed(error.localizedDescription)
132 + refreshStorageUsage()
133 + }
134 + }
135 +
136 + func answer(prompt: String) async throws -> String {
137 + await prepareIfNeeded()
138 +
139 + guard case .ready = availability else {
140 + throw OndeGuideError.unavailable
141 + }
142 +
143 + availability = .answering
144 + _ = await engine.clearHistory()
145 +
146 + do {
147 + let result = try await engine.sendMessage(message: prompt)
148 + availability = .ready
149 + refreshStorageUsage()
150 + return result.text.trimmingCharacters(in: .whitespacesAndNewlines)
151 + } catch {
152 + availability = .failed(error.localizedDescription)
153 + throw error
154 + }
155 + }
156 +
157 + func removePrivateGuide() async {
158 + _ = await engine.unloadModel()
159 + OndeEnvironmentBootstrap.clearPrivateGuideFiles()
160 + availability = .notInstalled
161 + refreshStorageUsage()
162 + }
163 +
164 + func refreshStorageUsage() {
165 + storageUsedDescription = OndeEnvironmentBootstrap.storageUsageDescription()
166 +
167 + if storageUsedDescription == "Not downloaded yet" {
168 + if case .downloaded = availability {
169 + availability = .notInstalled
170 + }
171 + } else if case .notInstalled = availability {
172 + availability = .downloaded
173 + }
174 + }
175 +
176 + func reset() {
177 + if case .failed = availability {
178 + availability = .notInstalled
179 + }
180 + refreshStorageUsage()
181 + }
182 +}
Klepon/App/AppRootView.swift new
+67
@@ -0,0 +1,67 @@
1 +import SwiftUI
2 +
3 +struct AppRootView: View {
4 + private enum Tab {
5 + case discover
6 + case search
7 + case saved
8 + }
9 +
10 + @EnvironmentObject private var appState: AppState
11 +
12 + @State private var selectedTab: Tab = .discover
13 + @State private var showingSettings = false
14 + @State private var showingOnboarding = false
15 +
16 + var body: some View {
17 + TabView(selection: $selectedTab) {
18 + NavigationStack {
19 + DiscoverView(showingSettings: $showingSettings)
20 + }
21 + .tabItem {
22 + Label("Discover", systemImage: "sparkles")
23 + }
24 + .tag(Tab.discover)
25 +
26 + NavigationStack {
27 + SearchView()
28 + }
29 + .tabItem {
30 + Label("Search", systemImage: "magnifyingglass")
31 + }
32 + .tag(Tab.search)
33 +
34 + NavigationStack {
35 + SavedView()
36 + }
37 + .tabItem {
38 + Label("Saved", systemImage: "heart")
39 + }
40 + .tag(Tab.saved)
41 + }
42 + .tint(KleponColor.accent)
43 + .background(KleponColor.background.ignoresSafeArea())
44 + .sheet(isPresented: $showingSettings) {
45 + NavigationStack {
46 + SettingsView()
47 + }
48 + }
49 + .fullScreenCover(isPresented: $showingOnboarding) {
50 + OnboardingView(
51 + onBrowseFirst: {
52 + appState.completeOnboarding()
53 + },
54 + onComplete: {
55 + appState.completeOnboarding()
56 + }
57 + )
58 + }
59 + .task {
60 + showingOnboarding = !appState.hasCompletedOnboarding
61 + appState.guideEngine.refreshStorageUsage()
62 + }
63 + .onChange(of: appState.hasCompletedOnboarding) { _, newValue in
64 + showingOnboarding = !newValue
65 + }
66 + }
67 +}
Klepon/App/AppState.swift new
+48
@@ -0,0 +1,48 @@
1 +import Combine
2 +import Foundation
3 +
4 +@MainActor
5 +final class AppState: ObservableObject {
6 + @Published var hasCompletedOnboarding: Bool {
7 + didSet {
8 + UserDefaults.standard.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding)
9 + }
10 + }
11 +
12 + let contentRepository: ContentRepository
13 + let searchService: SearchService
14 + let favoritesStore: FavoritesStore
15 + let recentSearchStore: RecentSearchStore
16 + let recentlyViewedStore: RecentlyViewedStore
17 + let guideEngine: OndeGuideEngine
18 + let answerService: GuideAnswerService
19 +
20 + init() {
21 + OndeEnvironmentBootstrap.configureIfNeeded()
22 +
23 + let repository = ContentRepository()
24 + let favoritesStore = FavoritesStore()
25 + let recentSearchStore = RecentSearchStore()
26 + let recentlyViewedStore = RecentlyViewedStore()
27 + let guideEngine = OndeGuideEngine()
28 +
29 + self.contentRepository = repository
30 + self.searchService = SearchService(repository: repository)
31 + self.favoritesStore = favoritesStore
32 + self.recentSearchStore = recentSearchStore
33 + self.recentlyViewedStore = recentlyViewedStore
34 + self.guideEngine = guideEngine
35 + self.answerService = GuideAnswerService(repository: repository, guideEngine: guideEngine)
36 + guideEngine.refreshStorageUsage()
37 + self.hasCompletedOnboarding = UserDefaults.standard.bool(
38 + forKey: Keys.hasCompletedOnboarding)
39 + }
40 +
41 + func completeOnboarding() {
42 + hasCompletedOnboarding = true
43 + }
44 +}
45 +
46 +private enum Keys {
47 + static let hasCompletedOnboarding = "klepon.hasCompletedOnboarding"
48 +}
Klepon/App/KleponApp.swift new
+18
@@ -0,0 +1,18 @@
1 +import SwiftUI
2 +
3 +@main
4 +struct KleponApp: App {
5 + @StateObject private var appState = AppState()
6 +
7 + var body: some Scene {
8 + WindowGroup {
9 + AppRootView()
10 + .environmentObject(appState)
11 + .environmentObject(appState.favoritesStore)
12 + .environmentObject(appState.recentSearchStore)
13 + .environmentObject(appState.recentlyViewedStore)
14 + .environmentObject(appState.guideEngine)
15 + .preferredColorScheme(.light)
16 + }
17 + }
18 +}
Klepon/Assets.xcassets/AccentColor.colorset/Contents.json new
+11
@@ -0,0 +1,11 @@
1 +{
2 + "colors" : [
3 + {
4 + "idiom" : "universal"
5 + }
6 + ],
7 + "info" : {
8 + "author" : "xcode",
9 + "version" : 1
10 + }
11 +}
Klepon/Assets.xcassets/AppIcon.appiconset/Contents.json new
+17
@@ -0,0 +1,17 @@
1 +{
2 + "images" : [
3 + { "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" },
4 + { "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "Icon-20@3x.png" },
5 + { "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" },
6 + { "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "Icon-29@3x.png" },
7 + { "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" },
8 + { "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "Icon-40@3x.png" },
9 + { "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "Icon-60@2x.png" },
10 + { "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "Icon-60@3x.png" },
11 + { "idiom" : "ios-marketing", "size" : "1024x1024", "scale" : "1x", "filename" : "Icon-1024.png" }
12 + ],
13 + "info" : {
14 + "author" : "xcode",
15 + "version" : 1
16 + }
17 +}
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ
Klepon/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png
Binary files /dev/null and b/Klepon/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ
Klepon/Assets.xcassets/Contents.json new
+6
@@ -0,0 +1,6 @@
1 +{
2 + "info" : {
3 + "author" : "xcode",
4 + "version" : 1
5 + }
6 +}
Klepon/Data/ContentRepository.swift new
+65
@@ -0,0 +1,65 @@
1 +import Foundation
2 +
3 +final class ContentRepository {
4 + let entries: [GuideEntry]
5 + let collections: [GuideCollection]
6 +
7 + private let entriesByID: [String: GuideEntry]
8 +
9 + init(bundle: Bundle = .main) {
10 + self.entries = ContentRepository.loadEntries(from: bundle)
11 + self.collections = ContentRepository.loadCollections(from: bundle)
12 + self.entriesByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
13 + }
14 +
15 + var featuredEntries: [GuideEntry] {
16 + entries.filter(\.isFeatured)
17 + }
18 +
19 + func entry(id: String) -> GuideEntry? {
20 + entriesByID[id]
21 + }
22 +
23 + func entries(ids: [String]) -> [GuideEntry] {
24 + ids.compactMap { entriesByID[$0] }
25 + }
26 +
27 + func relatedEntries(for entry: GuideEntry) -> [GuideEntry] {
28 + entries(ids: entry.relatedIDs)
29 + }
30 +
31 + func entries(in collection: GuideCollection) -> [GuideEntry] {
32 + entries(ids: collection.entryIDs)
33 + }
34 +
35 + private static func loadEntries(from bundle: Bundle) -> [GuideEntry] {
36 + loadJSON(named: "guide_entries", from: bundle) ?? []
37 + }
38 +
39 + private static func loadCollections(from bundle: Bundle) -> [GuideCollection] {
40 + loadJSON(named: "collections", from: bundle) ?? []
41 + }
42 +
43 + private static func loadJSON<T: Decodable>(named fileName: String, from bundle: Bundle) -> T? {
44 + let decoder = JSONDecoder()
45 + let candidateURLs = [
46 + bundle.url(
47 + forResource: fileName, withExtension: "json", subdirectory: "Resources/Content"),
48 + bundle.url(forResource: fileName, withExtension: "json"),
49 + ]
50 +
51 + for candidate in candidateURLs {
52 + guard let url = candidate else { continue }
53 +
54 + do {
55 + let data = try Data(contentsOf: url)
56 + return try decoder.decode(T.self, from: data)
57 + } catch {
58 + assertionFailure("Failed to decode \(fileName).json: \(error.localizedDescription)")
59 + }
60 + }
61 +
62 + assertionFailure("Missing bundled content file: \(fileName).json")
63 + return nil
64 + }
65 +}
Klepon/Data/FavoritesStore.swift new
+38
@@ -0,0 +1,38 @@
1 +import Combine
2 +import Foundation
3 +
4 +@MainActor
5 +final class FavoritesStore: ObservableObject {
6 + @Published private(set) var favoriteIDs: Set<String>
7 +
8 + private let defaults: UserDefaults
9 + private let storageKey = "klepon.favoriteIDs"
10 +
11 + init(defaults: UserDefaults = .standard) {
12 + self.defaults = defaults
13 + self.favoriteIDs = Set(defaults.stringArray(forKey: storageKey) ?? [])
14 + }
15 +
16 + func isFavorite(_ entryID: String) -> Bool {
17 + favoriteIDs.contains(entryID)
18 + }
19 +
20 + func toggle(_ entryID: String) {
21 + if favoriteIDs.contains(entryID) {
22 + favoriteIDs.remove(entryID)
23 + } else {
24 + favoriteIDs.insert(entryID)
25 + }
26 +
27 + persist()
28 + }
29 +
30 + func clear() {
31 + favoriteIDs = []
32 + persist()
33 + }
34 +
35 + private func persist() {
36 + defaults.set(Array(favoriteIDs).sorted(), forKey: storageKey)
37 + }
38 +}
Klepon/Data/RecentSearchStore.swift new
+40
@@ -0,0 +1,40 @@
1 +import Combine
2 +import Foundation
3 +
4 +@MainActor
5 +final class RecentSearchStore: ObservableObject {
6 + @Published private(set) var queries: [String]
7 +
8 + private let defaults: UserDefaults
9 + private let storageKey = "klepon.recentSearches"
10 + private let limit = 8
11 +
12 + init(defaults: UserDefaults = .standard) {
13 + self.defaults = defaults
14 + self.queries = defaults.stringArray(forKey: storageKey) ?? []
15 + }
16 +
17 + func record(_ query: String) {
18 + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
19 + guard !trimmedQuery.isEmpty else { return }
20 +
21 + queries.removeAll { $0.caseInsensitiveCompare(trimmedQuery) == .orderedSame }
22 + queries.insert(trimmedQuery, at: 0)
23 + queries = Array(queries.prefix(limit))
24 + persist()
25 + }
26 +
27 + func remove(_ query: String) {
28 + queries.removeAll { $0 == query }
29 + persist()
30 + }
31 +
32 + func clear() {
33 + queries = []
34 + persist()
35 + }
36 +
37 + private func persist() {
38 + defaults.set(queries, forKey: storageKey)
39 + }
40 +}
Klepon/Data/RecentlyViewedStore.swift new
+32
@@ -0,0 +1,32 @@
1 +import Combine
2 +import Foundation
3 +
4 +@MainActor
5 +final class RecentlyViewedStore: ObservableObject {
6 + @Published private(set) var entryIDs: [String]
7 +
8 + private let defaults: UserDefaults
9 + private let storageKey = "klepon.recentlyViewed"
10 + private let limit = 10
11 +
12 + init(defaults: UserDefaults = .standard) {
13 + self.defaults = defaults
14 + self.entryIDs = defaults.stringArray(forKey: storageKey) ?? []
15 + }
16 +
17 + func record(_ entryID: String) {
18 + entryIDs.removeAll { $0 == entryID }
19 + entryIDs.insert(entryID, at: 0)
20 + entryIDs = Array(entryIDs.prefix(limit))
21 + persist()
22 + }
23 +
24 + func clear() {
25 + entryIDs = []
26 + persist()
27 + }
28 +
29 + private func persist() {
30 + defaults.set(entryIDs, forKey: storageKey)
31 + }
32 +}
Klepon/Data/SearchService.swift new
+52
@@ -0,0 +1,52 @@
1 +import Foundation
2 +
3 +final class SearchService {
4 + private let repository: ContentRepository
5 +
6 + init(repository: ContentRepository) {
7 + self.repository = repository
8 + }
9 +
10 + func results(for query: String) -> [GuideEntry] {
11 + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
12 + guard !trimmedQuery.isEmpty else { return [] }
13 +
14 + let needle = trimmedQuery.lowercased()
15 +
16 + let rankedEntries: [(entry: GuideEntry, score: Int)] = repository.entries.compactMap {
17 + entry in
18 + let score = score(for: entry, needle: needle)
19 + guard score > 0 else { return nil }
20 + return (entry: entry, score: score)
21 + }
22 +
23 + return
24 + rankedEntries
25 + .sorted { lhs, rhs in
26 + if lhs.score == rhs.score {
27 + return lhs.entry.title < rhs.entry.title
28 + }
29 + return lhs.score > rhs.score
30 + }
31 + .map(\.entry)
32 + }
33 +
34 + func suggestions(limit: Int = 6) -> [GuideEntry] {
35 + Array(repository.featuredEntries.prefix(limit))
36 + }
37 +
38 + private func score(for entry: GuideEntry, needle: String) -> Int {
39 + let title = entry.title.lowercased()
40 + let subtitle = entry.subtitle.lowercased()
41 +
42 + if title == needle { return 120 }
43 + if entry.aliases.map({ $0.lowercased() }).contains(needle) { return 100 }
44 + if title.hasPrefix(needle) { return 90 }
45 + if title.contains(needle) { return 80 }
46 + if subtitle.contains(needle) { return 60 }
47 + if entry.tags.contains(where: { $0.lowercased().contains(needle) }) { return 45 }
48 + if entry.searchTokens.contains(where: { $0.lowercased().contains(needle) }) { return 30 }
49 +
50 + return 0
51 + }
52 +}
Klepon/DesignSystem/EmptyStateView.swift new
+30
@@ -0,0 +1,30 @@
1 +import SwiftUI
2 +
3 +struct EmptyStateView: View {
4 + let title: String
5 + let message: String
6 + let systemImage: String
7 +
8 + var body: some View {
9 + VStack(spacing: 16) {
10 + Image(systemName: systemImage)
11 + .font(.system(size: 36))
12 + .foregroundStyle(KleponColor.accent)
13 +
14 + Text(title)
15 + .font(KleponTypography.sectionTitle)
16 + .foregroundStyle(KleponColor.textPrimary)
17 +
18 + Text(message)
19 + .font(KleponTypography.bodySecondary)
20 + .foregroundStyle(KleponColor.textSecondary)
21 + .multilineTextAlignment(.center)
22 + }
23 + .padding(24)
24 + .frame(maxWidth: .infinity)
25 + .background(
26 + RoundedRectangle(cornerRadius: 24, style: .continuous)
27 + .fill(KleponColor.surface)
28 + )
29 + }
30 +}
Klepon/DesignSystem/GuideArtworkView.swift new
+55
@@ -0,0 +1,55 @@
1 +import SwiftUI
2 +
3 +struct GuideArtworkView: View {
4 + let entry: GuideEntry
5 + var height: CGFloat = 160
6 +
7 + var body: some View {
8 + ZStack(alignment: .bottomLeading) {
9 + RoundedRectangle(cornerRadius: 22, style: .continuous)
10 + .fill(
11 + LinearGradient(
12 + colors: gradientColors,
13 + startPoint: .topLeading,
14 + endPoint: .bottomTrailing
15 + )
16 + )
17 +
18 + Circle()
19 + .fill(Color.white.opacity(0.16))
20 + .frame(width: height * 0.9, height: height * 0.9)
21 + .offset(x: height * 0.2, y: -height * 0.15)
22 +
23 + Image(systemName: entry.kind.symbolName)
24 + .font(.system(size: height * 0.38, weight: .regular))
25 + .foregroundStyle(.white.opacity(0.85))
26 + .offset(x: -10, y: -6)
27 +
28 + VStack(alignment: .leading, spacing: 8) {
29 + Text(entry.title)
30 + .font(KleponTypography.cardTitle)
31 + .foregroundStyle(.white)
32 +
33 + Text(entry.subtitle)
34 + .font(KleponTypography.caption)
35 + .foregroundStyle(.white.opacity(0.88))
36 + .lineLimit(2)
37 + }
38 + .padding(18)
39 + }
40 + .frame(maxWidth: .infinity)
41 + .frame(height: height)
42 + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
43 + }
44 +
45 + private var gradientColors: [Color] {
46 + switch entry.kind {
47 + case .dish:
48 + return [KleponColor.accentWarm, KleponColor.highlight]
49 + case .ingredient:
50 + return [KleponColor.accent, KleponColor.accentWarm]
51 + case .tradition:
52 + return [KleponColor.accent, KleponColor.highlight]
53 + }
54 + }
55 +}
Klepon/DesignSystem/KleponActionButton.swift new
+59
@@ -0,0 +1,59 @@
1 +import SwiftUI
2 +
3 +struct KleponActionButton: View {
4 + enum Tone {
5 + case primary
6 + case secondary
7 + }
8 +
9 + let title: String
10 + var systemImage: String? = nil
11 + var tone: Tone = .primary
12 + var isLoading = false
13 + var isDisabled = false
14 + let action: () -> Void
15 +
16 + var body: some View {
17 + Button(action: action) {
18 + HStack(spacing: 10) {
19 + if isLoading {
20 + ProgressView()
21 + .tint(foregroundColor)
22 + } else if let systemImage {
23 + Image(systemName: systemImage)
24 + }
25 +
26 + Text(title)
27 + .font(KleponTypography.bodySecondary.weight(.semibold))
28 + }
29 + .frame(maxWidth: .infinity)
30 + .padding(.vertical, 13)
31 + .background(
32 + RoundedRectangle(cornerRadius: 18, style: .continuous)
33 + .fill(backgroundColor)
34 + )
35 + .foregroundStyle(foregroundColor)
36 + }
37 + .buttonStyle(.plain)
38 + .disabled(isDisabled || isLoading)
39 + .opacity((isDisabled || isLoading) && tone == .primary ? 0.85 : 1)
40 + }
41 +
42 + private var backgroundColor: Color {
43 + switch tone {
44 + case .primary:
45 + return KleponColor.accent
46 + case .secondary:
47 + return KleponColor.surface
48 + }
49 + }
50 +
51 + private var foregroundColor: Color {
52 + switch tone {
53 + case .primary:
54 + return .white
55 + case .secondary:
56 + return KleponColor.textPrimary
57 + }
58 + }
59 +}
Klepon/DesignSystem/KleponCard.swift new
+25
@@ -0,0 +1,25 @@
1 +import SwiftUI
2 +
3 +struct KleponCard<Content: View>: View {
4 + let padding: CGFloat
5 + @ViewBuilder var content: Content
6 +
7 + init(padding: CGFloat = 18, @ViewBuilder content: () -> Content) {
8 + self.padding = padding
9 + self.content = content()
10 + }
11 +
12 + var body: some View {
13 + content
14 + .padding(padding)
15 + .frame(maxWidth: .infinity, alignment: .leading)
16 + .background(
17 + RoundedRectangle(cornerRadius: 24, style: .continuous)
18 + .fill(KleponColor.surface)
19 + )
20 + .overlay(
21 + RoundedRectangle(cornerRadius: 24, style: .continuous)
22 + .stroke(KleponColor.divider, lineWidth: 1)
23 + )
24 + }
25 +}
Klepon/DesignSystem/KleponChip.swift new
+22
@@ -0,0 +1,22 @@
1 +import SwiftUI
2 +
3 +struct KleponChip: View {
4 + let title: String
5 + var icon: String? = nil
6 +
7 + var body: some View {
8 + HStack(spacing: 6) {
9 + if let icon {
10 + Image(systemName: icon)
11 + .font(.caption)
12 + }
13 +
14 + Text(title)
15 + .font(KleponTypography.chip)
16 + }
17 + .foregroundStyle(KleponColor.textPrimary)
18 + .padding(.horizontal, 12)
19 + .padding(.vertical, 8)
20 + .background(Capsule().fill(KleponColor.chipBackground))
21 + }
22 +}
Klepon/DesignSystem/KleponColor.swift new
+14
@@ -0,0 +1,14 @@
1 +import SwiftUI
2 +
3 +enum KleponColor {
4 + static let background = Color(red: 0.98, green: 0.96, blue: 0.92)
5 + static let surface = Color.white.opacity(0.88)
6 + static let surfaceSecondary = Color(red: 0.93, green: 0.90, blue: 0.84)
7 + static let accent = Color(red: 0.17, green: 0.48, blue: 0.34)
8 + static let accentWarm = Color(red: 0.56, green: 0.35, blue: 0.17)
9 + static let highlight = Color(red: 0.78, green: 0.21, blue: 0.16)
10 + static let textPrimary = Color(red: 0.17, green: 0.16, blue: 0.13)
11 + static let textSecondary = Color(red: 0.37, green: 0.35, blue: 0.31)
12 + static let chipBackground = Color(red: 0.94, green: 0.92, blue: 0.87)
13 + static let divider = Color.black.opacity(0.08)
14 +}
Klepon/DesignSystem/KleponMetadataRow.swift new
+20
@@ -0,0 +1,20 @@
1 +import SwiftUI
2 +
3 +struct KleponMetadataRow: View {
4 + let title: String
5 + let value: String
6 +
7 + var body: some View {
8 + HStack(alignment: .top, spacing: 16) {
9 + Text(title)
10 + .font(KleponTypography.caption)
11 + .foregroundStyle(KleponColor.accentWarm)
12 + .frame(width: 110, alignment: .leading)
13 +
14 + Text(value)
15 + .font(KleponTypography.bodySecondary)
16 + .foregroundStyle(KleponColor.textPrimary)
17 + .frame(maxWidth: .infinity, alignment: .leading)
18 + }
19 + }
20 +}
Klepon/DesignSystem/KleponTypography.swift new
+12
@@ -0,0 +1,12 @@
1 +import SwiftUI
2 +
3 +enum KleponTypography {
4 + static let heroTitle = Font.system(size: 34, weight: .semibold, design: .serif)
5 + static let screenTitle = Font.system(size: 28, weight: .semibold, design: .serif)
6 + static let sectionTitle = Font.system(size: 20, weight: .semibold, design: .serif)
7 + static let cardTitle = Font.system(size: 18, weight: .semibold, design: .default)
8 + static let body = Font.system(size: 17, weight: .regular, design: .default)
9 + static let bodySecondary = Font.system(size: 15, weight: .regular, design: .default)
10 + static let caption = Font.system(size: 13, weight: .medium, design: .default)
11 + static let chip = Font.system(size: 13, weight: .semibold, design: .default)
12 +}
Klepon/DesignSystem/SectionHeader.swift new
+26
@@ -0,0 +1,26 @@
1 +import SwiftUI
2 +
3 +struct SectionHeader: View {
4 + let title: String
5 + let subtitle: String?
6 +
7 + init(_ title: String, subtitle: String? = nil) {
8 + self.title = title
9 + self.subtitle = subtitle
10 + }
11 +
12 + var body: some View {
13 + VStack(alignment: .leading, spacing: 4) {
14 + Text(title)
15 + .font(KleponTypography.sectionTitle)
16 + .foregroundStyle(KleponColor.textPrimary)
17 +
18 + if let subtitle {
19 + Text(subtitle)
20 + .font(KleponTypography.bodySecondary)
21 + .foregroundStyle(KleponColor.textSecondary)
22 + }
23 + }
24 + .frame(maxWidth: .infinity, alignment: .leading)
25 + }
26 +}
Klepon/Domain/AnswerCard.swift new
+12
@@ -0,0 +1,12 @@
1 +import Foundation
2 +
3 +struct AnswerCard: Identifiable, Equatable {
4 + let id = UUID()
5 + let question: String
6 + let headline: String
7 + let body: String
8 + let highlights: [String]
9 + let followUpSuggestions: [String]
10 + let sourceEntryIDs: [String]
11 + let isGeneratedOnDevice: Bool
12 +}
Klepon/Domain/GuideCollection.swift new
+8
@@ -0,0 +1,8 @@
1 +import Foundation
2 +
3 +struct GuideCollection: Identifiable, Codable, Hashable {
4 + let id: String
5 + let title: String
6 + let subtitle: String
7 + let entryIDs: [String]
8 +}
Klepon/Domain/GuideEntry.swift new
+51
@@ -0,0 +1,51 @@
1 +import Foundation
2 +
3 +struct GuideEntry: Identifiable, Codable, Hashable {
4 + enum Kind: String, Codable, CaseIterable {
5 + case dish
6 + case ingredient
7 + case tradition
8 +
9 + var displayTitle: String {
10 + switch self {
11 + case .dish:
12 + return "Dish"
13 + case .ingredient:
14 + return "Ingredient"
15 + case .tradition:
16 + return "Tradition"
17 + }
18 + }
19 +
20 + var symbolName: String {
21 + switch self {
22 + case .dish:
23 + return "fork.knife"
24 + case .ingredient:
25 + return "leaf"
26 + case .tradition:
27 + return "sparkles"
28 + }
29 + }
30 + }
31 +
32 + let id: String
33 + let kind: Kind
34 + let title: String
35 + let subtitle: String
36 + let summary: String
37 + let story: String
38 + let tasteNotes: [String]
39 + let highlights: [String]
40 + let region: String?
41 + let aliases: [String]
42 + let tags: [String]
43 + let relatedIDs: [String]
44 + let suggestedQuestions: [String]
45 + let imageName: String?
46 + let isFeatured: Bool
47 +
48 + var searchTokens: [String] {
49 + [title, subtitle, summary] + aliases + tags + tasteNotes
50 + }
51 +}
Klepon/Features/Ask/AskSheetView.swift new
+369
@@ -0,0 +1,369 @@
1 +import SwiftUI
2 +
3 +struct AskSheetView: View {
4 + let entry: GuideEntry
5 + let initialQuestion: String?
6 +
7 + @Environment(\.dismiss) private var dismiss
8 + @EnvironmentObject private var appState: AppState
9 + @EnvironmentObject private var guideEngine: OndeGuideEngine
10 +
11 + @State private var question = ""
12 + @State private var answerHistory: [AnswerCard] = []
13 + @State private var isLoading = false
14 +
15 + private var latestAnswerCard: AnswerCard? {
16 + answerHistory.last
17 + }
18 +
19 + private var earlierAnswerCards: [AnswerCard] {
20 + Array(answerHistory.dropLast())
21 + }
22 +
23 + private func sourceTitles(for card: AnswerCard) -> [String] {
24 + card.sourceEntryIDs.compactMap { appState.contentRepository.entry(id: $0)?.title }
25 + }
26 +
27 + var body: some View {
28 + ScrollView {
29 + VStack(alignment: .leading, spacing: 20) {
30 + KleponCard {
31 + VStack(alignment: .leading, spacing: 12) {
32 + Text("Ask privately about \(entry.title)")
33 + .font(KleponTypography.screenTitle)
34 + .foregroundStyle(KleponColor.textPrimary)
35 +
36 + Text(
37 + "Ask a natural follow-up, keep it short, and let the guide stay in the background."
38 + )
39 + .font(KleponTypography.body)
40 + .foregroundStyle(KleponColor.textSecondary)
41 +
42 + Text(guideEngine.availability.title)
43 + .font(KleponTypography.caption)
44 + .foregroundStyle(
45 + guideEngine.availability.isFailure
46 + ? KleponColor.highlight : KleponColor.accentWarm)
47 +
48 + Text(guideEngine.availability.detail)
49 + .font(KleponTypography.bodySecondary)
50 + .foregroundStyle(KleponColor.textSecondary)
51 + }
52 + }
53 +
54 + VStack(alignment: .leading, spacing: 12) {
55 + SectionHeader("Suggested questions")
56 +
57 + LazyVGrid(
58 + columns: [GridItem(.adaptive(minimum: 140), spacing: 10)], spacing: 10
59 + ) {
60 + ForEach(entry.suggestedQuestions, id: \.self) { suggestion in
61 + Button {
62 + question = suggestion
63 + submit(question: suggestion)
64 + } label: {
65 + Text(suggestion)
66 + .font(KleponTypography.bodySecondary)
67 + .multilineTextAlignment(.leading)
68 + .frame(maxWidth: .infinity, alignment: .leading)
69 + .padding(12)
70 + .background(
71 + RoundedRectangle(cornerRadius: 18, style: .continuous)
72 + .fill(KleponColor.surfaceSecondary)
73 + )
74 + .foregroundStyle(KleponColor.textPrimary)
75 + }
76 + .buttonStyle(.plain)
77 + .disabled(isLoading)
78 + }
79 + }
80 + }
81 +
82 + VStack(alignment: .leading, spacing: 12) {
83 + SectionHeader("Your question")
84 +
85 + TextField(
86 + "Ask about taste, ingredients, or what to try next", text: $question,
87 + axis: .vertical
88 + )
89 + .textFieldStyle(.roundedBorder)
90 + .lineLimit(2...5)
91 +
92 + KleponActionButton(
93 + title: isLoading ? "Thinking" : "Get answer",
94 + systemImage: "lock",
95 + isLoading: isLoading,
96 + isDisabled: question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
97 + ) {
98 + submit(question: question)
99 + }
100 +
101 + if guideEngine.availability.isFailure {
102 + KleponActionButton(
103 + title: "Retry private guide",
104 + systemImage: "arrow.clockwise",
105 + tone: .secondary
106 + ) {
107 + Task {
108 + await guideEngine.prepareIfNeeded(forceReload: true)
109 + }
110 + }
111 + }
112 + }
113 +
114 + if isLoading {
115 + AnswerLoadingCard()
116 + }
117 +
118 + if let latestAnswerCard {
119 + AnswerCardView(
120 + card: latestAnswerCard,
121 + sourceTitles: sourceTitles(for: latestAnswerCard),
122 + isInteractionDisabled: isLoading,
123 + onFollowUp: { followUp in
124 + question = followUp
125 + submit(question: followUp)
126 + }
127 + )
128 + }
129 +
130 + if !earlierAnswerCards.isEmpty {
131 + VStack(alignment: .leading, spacing: 12) {
132 + SectionHeader("Earlier in this sheet")
133 +
134 + ForEach(Array(earlierAnswerCards.reversed()), id: \.id) { card in
135 + AnswerCardView(
136 + card: card,
137 + sourceTitles: sourceTitles(for: card),
138 + isInteractionDisabled: isLoading,
139 + onFollowUp: { followUp in
140 + question = followUp
141 + submit(question: followUp)
142 + }
143 + )
144 + }
145 + }
146 + }
147 +
148 + KleponCard {
149 + Text(
150 + "Private answers use the on-device guide when it is ready. If it is not ready yet, Klepon falls back to the curated notes already bundled in the app."
151 + )
152 + .font(KleponTypography.caption)
153 + .foregroundStyle(KleponColor.textSecondary)
154 + }
155 + }
156 + .padding(.horizontal, 20)
157 + .padding(.vertical, 16)
158 + }
159 + .background(KleponColor.background.ignoresSafeArea())
160 + .navigationTitle("Ask privately")
161 + .navigationBarTitleDisplayMode(.inline)
162 + .toolbar {
163 + ToolbarItem(placement: .topBarTrailing) {
164 + Button("Done") {
165 + dismiss()
166 + }
167 + .tint(KleponColor.accent)
168 + }
169 + }
170 + .task {
171 + guard let initialQuestion, question.isEmpty else { return }
172 + question = initialQuestion
173 + submit(question: initialQuestion)
174 + }
175 + }
176 +
177 + private func submit(question: String) {
178 + let trimmedQuestion = question.trimmingCharacters(in: .whitespacesAndNewlines)
179 + guard !trimmedQuestion.isEmpty, !isLoading else { return }
180 +
181 + isLoading = true
182 +
183 + Task {
184 + let result = await appState.answerService.answer(
185 + question: trimmedQuestion,
186 + about: entry,
187 + previousCards: answerHistory
188 + )
189 + answerHistory.append(result)
190 + if answerHistory.count > 3 {
191 + answerHistory = Array(answerHistory.suffix(3))
192 + }
193 + isLoading = false
194 + }
195 + }
196 +}
197 +
198 +private struct AnswerCardView: View {
199 + let card: AnswerCard
200 + let sourceTitles: [String]
201 + let isInteractionDisabled: Bool
202 + let onFollowUp: (String) -> Void
203 +
204 + var body: some View {
205 + KleponCard {
206 + VStack(alignment: .leading, spacing: 14) {
207 + HStack {
208 + Text(card.headline)
209 + .font(KleponTypography.cardTitle)
210 + .foregroundStyle(KleponColor.textPrimary)
211 +
212 + Spacer(minLength: 12)
213 +
214 + if card.isGeneratedOnDevice {
215 + KleponChip(title: "On this iPhone", icon: "lock")
216 + } else {
217 + KleponChip(title: "Curated fallback", icon: "book")
218 + }
219 + }
220 +
221 + VStack(alignment: .leading, spacing: 14) {
222 + ForEach(parseSections(from: card.body), id: \.title) { section in
223 + VStack(alignment: .leading, spacing: 6) {
224 + Text(section.title)
225 + .font(KleponTypography.caption)
226 + .foregroundStyle(KleponColor.accentWarm)
227 +
228 + Text(section.body)
229 + .font(KleponTypography.body)
230 + .foregroundStyle(KleponColor.textSecondary)
231 + }
232 + }
233 + }
234 +
235 + if !card.highlights.isEmpty {
236 + FlexibleAnswerTagLayout(items: card.highlights)
237 + }
238 +
239 + if !sourceTitles.isEmpty {
240 + VStack(alignment: .leading, spacing: 8) {
241 + Text("Grounded in")
242 + .font(KleponTypography.caption)
243 + .foregroundStyle(KleponColor.accentWarm)
244 +
245 + FlexibleAnswerTagLayout(items: sourceTitles)
246 + }
247 + }
248 +
249 + if !card.followUpSuggestions.isEmpty {
250 + VStack(alignment: .leading, spacing: 10) {
251 + Text("Try a follow-up")
252 + .font(KleponTypography.caption)
253 + .foregroundStyle(KleponColor.accentWarm)
254 +
255 + ForEach(card.followUpSuggestions, id: \.self) { followUp in
256 + Button {
257 + onFollowUp(followUp)
258 + } label: {
259 + Text(followUp)
260 + .font(KleponTypography.bodySecondary)
261 + .frame(maxWidth: .infinity, alignment: .leading)
262 + .padding(12)
263 + .background(
264 + RoundedRectangle(cornerRadius: 16, style: .continuous)
265 + .fill(KleponColor.surfaceSecondary)
266 + )
267 + .foregroundStyle(KleponColor.textPrimary)
268 + }
269 + .buttonStyle(.plain)
270 + .disabled(isInteractionDisabled)
271 + }
272 + }
273 + }
274 + }
275 + }
276 + }
277 +}
278 +
279 +private struct AnswerLoadingCard: View {
280 + var body: some View {
281 + KleponCard {
282 + VStack(alignment: .leading, spacing: 12) {
283 + Text("Preparing your answer")
284 + .font(KleponTypography.cardTitle)
285 + .foregroundStyle(KleponColor.textPrimary)
286 +
287 + Text(
288 + "Klepon is grounding your question against the local guide and keeping the response short."
289 + )
290 + .font(KleponTypography.bodySecondary)
291 + .foregroundStyle(KleponColor.textSecondary)
292 +
293 + ProgressView()
294 + .tint(KleponColor.accent)
295 + }
296 + }
297 + }
298 +}
299 +
300 +private struct AnswerSection {
301 + let title: String
302 + let body: String
303 +}
304 +
305 +private func parseSections(from body: String) -> [AnswerSection] {
306 + let headings = ["Short answer", "What to expect", "Try next"]
307 + let lines =
308 + body
309 + .components(separatedBy: .newlines)
310 + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
311 + .filter { !$0.isEmpty }
312 +
313 + guard !lines.isEmpty else {
314 + return [AnswerSection(title: "Answer", body: body)]
315 + }
316 +
317 + var sections: [AnswerSection] = []
318 + var currentTitle: String?
319 + var currentBodyLines: [String] = []
320 +
321 + func flushSection() {
322 + guard let currentTitle, !currentBodyLines.isEmpty else { return }
323 + sections.append(
324 + AnswerSection(title: currentTitle, body: currentBodyLines.joined(separator: " ")))
325 + }
326 +
327 + for line in lines {
328 + if let heading = headings.first(where: { heading in
329 + let loweredLine = line.lowercased()
330 + let loweredHeading = heading.lowercased()
331 + return loweredLine == loweredHeading || loweredLine.hasPrefix(loweredHeading + ":")
332 + }) {
333 + flushSection()
334 + currentTitle = heading
335 + if let range = line.range(of: ":") {
336 + let remainder = String(line[range.upperBound...]).trimmingCharacters(
337 + in: .whitespaces)
338 + currentBodyLines = remainder.isEmpty ? [] : [remainder]
339 + } else {
340 + currentBodyLines = []
341 + }
342 + } else {
343 + currentBodyLines.append(line)
344 + }
345 + }
346 +
347 + flushSection()
348 +
349 + if sections.isEmpty {
350 + return [AnswerSection(title: "Answer", body: body)]
351 + }
352 +
353 + return sections
354 +}
355 +
356 +private struct FlexibleAnswerTagLayout: View {
357 + let items: [String]
358 +
359 + var body: some View {
360 + LazyVGrid(
361 + columns: [GridItem(.adaptive(minimum: 90), spacing: 10)], alignment: .leading,
362 + spacing: 10
363 + ) {
364 + ForEach(items, id: \.self) { item in
365 + KleponChip(title: item)
366 + }
367 + }
368 + }
369 +}
Klepon/Features/Detail/GuideDetailView.swift new
+226
@@ -0,0 +1,226 @@
1 +import SwiftUI
2 +
3 +struct GuideDetailView: View {
4 + let entry: GuideEntry
5 +
6 + @EnvironmentObject private var appState: AppState
7 + @EnvironmentObject private var favoritesStore: FavoritesStore
8 + @EnvironmentObject private var recentlyViewedStore: RecentlyViewedStore
9 +
10 + @State private var showingAskSheet = false
11 + @State private var initialQuestion: String?
12 +
13 + private var relatedEntries: [GuideEntry] {
14 + appState.contentRepository.relatedEntries(for: entry)
15 + }
16 +
17 + var body: some View {
18 + ScrollView {
19 + VStack(alignment: .leading, spacing: 20) {
20 + headerCard
21 + richTextSection(title: "What it is", text: entry.story)
22 +
23 + if !entry.tasteNotes.isEmpty {
24 + VStack(alignment: .leading, spacing: 12) {
25 + SectionHeader("What it tastes like")
26 +
27 + FlexibleTagLayout(items: entry.tasteNotes)
28 + }
29 + }
30 +
31 + if !entry.highlights.isEmpty {
32 + VStack(alignment: .leading, spacing: 12) {
33 + SectionHeader("Good to know")
34 +
35 + KleponCard {
36 + VStack(alignment: .leading, spacing: 12) {
37 + ForEach(entry.highlights, id: \.self) { item in
38 + Label(item, systemImage: "circle.fill")
39 + .font(KleponTypography.bodySecondary)
40 + .foregroundStyle(KleponColor.textSecondary)
41 + .labelStyle(DetailBulletLabelStyle())
42 + }
43 + }
44 + }
45 + }
46 + }
47 +
48 + if let region = entry.region {
49 + richTextSection(title: "Region or context", text: region)
50 + }
51 +
52 + askSection
53 +
54 + if !relatedEntries.isEmpty {
55 + VStack(alignment: .leading, spacing: 12) {
56 + SectionHeader("Similar dishes and related notes")
57 +
58 + ForEach(relatedEntries) { relatedEntry in
59 + NavigationLink {
60 + GuideDetailView(entry: relatedEntry)
61 + } label: {
62 + KleponCard {
63 + VStack(alignment: .leading, spacing: 10) {
64 + HStack {
65 + Text(relatedEntry.title)
66 + .font(KleponTypography.cardTitle)
67 + .foregroundStyle(KleponColor.textPrimary)
68 +
69 + Spacer(minLength: 12)
70 +
71 + Text(relatedEntry.kind.displayTitle)
72 + .font(KleponTypography.caption)
73 + .foregroundStyle(KleponColor.accentWarm)
74 + }
75 +
76 + Text(relatedEntry.summary)
77 + .font(KleponTypography.bodySecondary)
78 + .foregroundStyle(KleponColor.textSecondary)
79 + .lineLimit(2)
80 + }
81 + }
82 + }
83 + .buttonStyle(.plain)
84 + }
85 + }
86 + }
87 + }
88 + .padding(.horizontal, 20)
89 + .padding(.vertical, 16)
90 + }
91 + .background(KleponColor.background.ignoresSafeArea())
92 + .navigationTitle(entry.title)
93 + .navigationBarTitleDisplayMode(.inline)
94 + .toolbar {
95 + ToolbarItem(placement: .topBarTrailing) {
96 + Button {
97 + favoritesStore.toggle(entry.id)
98 + } label: {
99 + Image(systemName: favoritesStore.isFavorite(entry.id) ? "heart.fill" : "heart")
100 + }
101 + .tint(KleponColor.highlight)
102 + }
103 + }
104 + .sheet(isPresented: $showingAskSheet) {
105 + NavigationStack {
106 + AskSheetView(entry: entry, initialQuestion: initialQuestion)
107 + }
108 + }
109 + .onAppear {
110 + recentlyViewedStore.record(entry.id)
111 + }
112 + }
113 +
114 + private var headerCard: some View {
115 + KleponCard(padding: 20) {
116 + VStack(alignment: .leading, spacing: 16) {
117 + GuideArtworkView(entry: entry, height: 184)
118 +
119 + HStack(spacing: 10) {
120 + KleponChip(title: entry.kind.displayTitle, icon: entry.kind.symbolName)
121 +
122 + if let region = entry.region {
123 + KleponChip(title: region)
124 + }
125 + }
126 +
127 + Text(entry.summary)
128 + .font(KleponTypography.body)
129 + .foregroundStyle(KleponColor.textSecondary)
130 + }
131 + }
132 + }
133 +
134 + private func richTextSection(title: String, text: String) -> some View {
135 + VStack(alignment: .leading, spacing: 12) {
136 + SectionHeader(title)
137 +
138 + KleponCard {
139 + Text(text)
140 + .font(KleponTypography.body)
141 + .foregroundStyle(KleponColor.textSecondary)
142 + }
143 + }
144 + }
145 +
146 + private var askSection: some View {
147 + KleponCard {
148 + VStack(alignment: .leading, spacing: 14) {
149 + Text("Ask privately about \(entry.title)")
150 + .font(KleponTypography.cardTitle)
151 + .foregroundStyle(KleponColor.textPrimary)
152 +
153 + Text(
154 + "Get a warmer explanation, compare dishes, or ask what to try next without turning this into a generic chat screen."
155 + )
156 + .font(KleponTypography.bodySecondary)
157 + .foregroundStyle(KleponColor.textSecondary)
158 +
159 + if !entry.suggestedQuestions.isEmpty {
160 + VStack(alignment: .leading, spacing: 10) {
161 + Text("Try a quick question")
162 + .font(KleponTypography.caption)
163 + .foregroundStyle(KleponColor.accentWarm)
164 +
165 + LazyVGrid(
166 + columns: [GridItem(.adaptive(minimum: 150), spacing: 10)],
167 + alignment: .leading,
168 + spacing: 10
169 + ) {
170 + ForEach(Array(entry.suggestedQuestions.prefix(3)), id: \.self) {
171 + question in
172 + Button {
173 + initialQuestion = question
174 + showingAskSheet = true
175 + } label: {
176 + Text(question)
177 + .font(KleponTypography.bodySecondary)
178 + .multilineTextAlignment(.leading)
179 + .frame(maxWidth: .infinity, alignment: .leading)
180 + .padding(12)
181 + .background(
182 + RoundedRectangle(cornerRadius: 16, style: .continuous)
183 + .fill(KleponColor.surfaceSecondary)
184 + )
185 + .foregroundStyle(KleponColor.textPrimary)
186 + }
187 + .buttonStyle(.plain)
188 + }
189 + }
190 + }
191 + }
192 +
193 + KleponActionButton(title: "Ask privately", systemImage: "lock") {
194 + initialQuestion = nil
195 + showingAskSheet = true
196 + }
197 + }
198 + }
199 + }
200 +}
201 +
202 +private struct DetailBulletLabelStyle: LabelStyle {
203 + func makeBody(configuration: Configuration) -> some View {
204 + HStack(alignment: .top, spacing: 10) {
205 + configuration.icon
206 + .font(.system(size: 6))
207 + .padding(.top, 8)
208 + configuration.title
209 + }
210 + }
211 +}
212 +
213 +private struct FlexibleTagLayout: View {
214 + let items: [String]
215 +
216 + var body: some View {
217 + LazyVGrid(
218 + columns: [GridItem(.adaptive(minimum: 96), spacing: 10)], alignment: .leading,
219 + spacing: 10
220 + ) {
221 + ForEach(items, id: \.self) { item in
222 + KleponChip(title: item)
223 + }
224 + }
225 + }
226 +}
Klepon/Features/Discover/DiscoverView.swift new
+311
@@ -0,0 +1,311 @@
1 +import SwiftUI
2 +
3 +struct DiscoverView: View {
4 + @EnvironmentObject private var appState: AppState
5 + @EnvironmentObject private var guideEngine: OndeGuideEngine
6 + @EnvironmentObject private var recentlyViewedStore: RecentlyViewedStore
7 +
8 + @Binding var showingSettings: Bool
9 +
10 + private var featuredEntry: GuideEntry? {
11 + let featuredEntries = appState.contentRepository.featuredEntries
12 + guard !featuredEntries.isEmpty else { return nil }
13 + let dayIndex = Calendar.current.ordinality(of: .day, in: .year, for: Date()) ?? 0
14 + return featuredEntries[dayIndex % featuredEntries.count]
15 + }
16 +
17 + private var starterEntries: [GuideEntry] {
18 + Array(appState.contentRepository.featuredEntries.prefix(4))
19 + }
20 +
21 + private var featuredTasteNotes: [String] {
22 + ["Private on your iPhone", "Curated first", "Warm follow-up answers"]
23 + }
24 +
25 + private var recentlyViewedEntries: [GuideEntry] {
26 + recentlyViewedStore.entryIDs.compactMap { appState.contentRepository.entry(id: $0) }
27 + }
28 +
29 + var body: some View {
30 + ScrollView {
31 + VStack(alignment: .leading, spacing: 24) {
32 + introSection
33 +
34 + if let featuredEntry {
35 + SectionHeader("Featured today", subtitle: "A thoughtful place to start")
36 +
37 + NavigationLink {
38 + GuideDetailView(entry: featuredEntry)
39 + } label: {
40 + FeaturedEntryCard(entry: featuredEntry)
41 + }
42 + .buttonStyle(.plain)
43 + } else {
44 + EmptyStateView(
45 + title: "Curated dishes are on the way",
46 + message:
47 + "Add a few starter entries to your bundled content and Discover will come to life.",
48 + systemImage: "fork.knife.circle"
49 + )
50 + }
51 +
52 + if !appState.contentRepository.collections.isEmpty {
53 + VStack(alignment: .leading, spacing: 18) {
54 + SectionHeader(
55 + "Browse by collection",
56 + subtitle: "Curated paths through taste, place, and tradition")
57 +
58 + ForEach(appState.contentRepository.collections) { collection in
59 + CollectionRail(
60 + collection: collection,
61 + entries: appState.contentRepository.entries(in: collection))
62 + }
63 + }
64 + }
65 +
66 + if !starterEntries.isEmpty {
67 + VStack(alignment: .leading, spacing: 14) {
68 + SectionHeader(
69 + "Good first dishes",
70 + subtitle: "Iconic flavors to open the app with confidence")
71 +
72 + ForEach(starterEntries) { entry in
73 + NavigationLink {
74 + GuideDetailView(entry: entry)
75 + } label: {
76 + EntryListCard(entry: entry)
77 + }
78 + .buttonStyle(.plain)
79 + }
80 + }
81 + }
82 +
83 + if !recentlyViewedEntries.isEmpty {
84 + VStack(alignment: .leading, spacing: 14) {
85 + SectionHeader(
86 + "Pick up where you left off",
87 + subtitle: "Recently opened dishes, ingredients, and food notes")
88 +
89 + ForEach(Array(recentlyViewedEntries.prefix(3))) { entry in
90 + NavigationLink {
91 + GuideDetailView(entry: entry)
92 + } label: {
93 + EntryListCard(entry: entry)
94 + }
95 + .buttonStyle(.plain)
96 + }
97 + }
98 + }
99 +
100 + privateGuideCard
101 + }
102 + .padding(.horizontal, 20)
103 + .padding(.vertical, 16)
104 + }
105 + .background(KleponColor.background.ignoresSafeArea())
106 + .navigationTitle("Klepon")
107 + .navigationBarTitleDisplayMode(.large)
108 + .toolbar {
109 + ToolbarItem(placement: .topBarTrailing) {
110 + Button {
111 + showingSettings = true
112 + } label: {
113 + Image(systemName: "gearshape.2")
114 + }
115 + .tint(KleponColor.accent)
116 + }
117 + }
118 + }
119 +
120 + private var introSection: some View {
121 + KleponCard(padding: 22) {
122 + VStack(alignment: .leading, spacing: 14) {
123 + Text("Taste of Indonesia")
124 + .font(KleponTypography.heroTitle)
125 + .foregroundStyle(KleponColor.textPrimary)
126 +
127 + Text(
128 + "Explore dishes, ingredients, and food traditions with a guide that feels calm, personal, and private on your iPhone."
129 + )
130 + .font(KleponTypography.body)
131 + .foregroundStyle(KleponColor.textSecondary)
132 +
133 + FlexibleDiscoverTagLayout(items: featuredTasteNotes)
134 + }
135 + }
136 + }
137 +
138 + private var privateGuideCard: some View {
139 + KleponCard {
140 + VStack(alignment: .leading, spacing: 14) {
141 + HStack(alignment: .top, spacing: 12) {
142 + Image(systemName: guideEngine.availability == .ready ? "lock.fill" : "sparkles")
143 + .font(.title3)
144 + .foregroundStyle(KleponColor.accent)
145 + .padding(10)
146 + .background(Circle().fill(KleponColor.surfaceSecondary))
147 +
148 + VStack(alignment: .leading, spacing: 6) {
149 + Text("Ask privately")
150 + .font(KleponTypography.cardTitle)
151 + .foregroundStyle(KleponColor.textPrimary)
152 +
153 + Text(guideEngine.availability.title)
154 + .font(KleponTypography.caption)
155 + .foregroundStyle(
156 + guideEngine.availability.isFailure
157 + ? KleponColor.highlight : KleponColor.accentWarm)
158 + }
159 + }
160 +
161 + Text(guideEngine.availability.detail)
162 + .font(KleponTypography.bodySecondary)
163 + .foregroundStyle(KleponColor.textSecondary)
164 +
165 + HStack(spacing: 10) {
166 + KleponChip(
167 + title: guideEngine.estimatedDownloadDescription, icon: "arrow.down.circle")
168 + KleponChip(title: guideEngine.storageUsedDescription, icon: "internaldrive")
169 + }
170 +
171 + if guideEngine.availability == .ready {
172 + Text(
173 + "Open any dish to ask taste, ingredient, or comparison questions without leaving the guide."
174 + )
175 + .font(KleponTypography.caption)
176 + .foregroundStyle(KleponColor.accentWarm)
177 + }
178 +
179 + KleponActionButton(
180 + title: guideEngine.availability.actionTitle,
181 + systemImage: guideEngine.availability == .ready
182 + ? "checkmark.circle.fill" : "arrow.down.circle",
183 + isLoading: guideEngine.availability.isBusy,
184 + isDisabled: guideEngine.availability == .ready
185 + ) {
186 + Task {
187 + await guideEngine.prepareIfNeeded(
188 + forceReload: guideEngine.availability.isFailure)
189 + }
190 + }
191 + }
192 + }
193 + }
194 +}
195 +
196 +private struct FeaturedEntryCard: View {
197 + let entry: GuideEntry
198 +
199 + var body: some View {
200 + KleponCard(padding: 20) {
201 + VStack(alignment: .leading, spacing: 14) {
202 + GuideArtworkView(entry: entry, height: 178)
203 +
204 + KleponChip(title: entry.kind.displayTitle, icon: entry.kind.symbolName)
205 +
206 + Text(entry.summary)
207 + .font(KleponTypography.body)
208 + .foregroundStyle(KleponColor.textSecondary)
209 +
210 + FlexibleDiscoverTagLayout(items: Array(entry.tasteNotes.prefix(3)))
211 + }
212 + }
213 + }
214 +}
215 +
216 +private struct EntryListCard: View {
217 + let entry: GuideEntry
218 +
219 + var body: some View {
220 + KleponCard {
221 + VStack(alignment: .leading, spacing: 10) {
222 + HStack {
223 + Text(entry.title)
224 + .font(KleponTypography.cardTitle)
225 + .foregroundStyle(KleponColor.textPrimary)
226 +
227 + Spacer(minLength: 12)
228 +
229 + Image(systemName: entry.kind.symbolName)
230 + .foregroundStyle(KleponColor.accentWarm)
231 + }
232 +
233 + Text(entry.summary)
234 + .font(KleponTypography.bodySecondary)
235 + .foregroundStyle(KleponColor.textSecondary)
236 + .lineLimit(2)
237 + }
238 + }
239 + }
240 +}
241 +
242 +private struct FlexibleDiscoverTagLayout: View {
243 + let items: [String]
244 +
245 + var body: some View {
246 + LazyVGrid(
247 + columns: [GridItem(.adaptive(minimum: 110), spacing: 10)], alignment: .leading,
248 + spacing: 10
249 + ) {
250 + ForEach(items, id: \.self) { item in
251 + KleponChip(title: item)
252 + }
253 + }
254 + }
255 +}
256 +
257 +private struct CollectionRail: View {
258 + let collection: GuideCollection
259 + let entries: [GuideEntry]
260 +
261 + var body: some View {
262 + VStack(alignment: .leading, spacing: 12) {
263 + VStack(alignment: .leading, spacing: 4) {
264 + Text(collection.title)
265 + .font(KleponTypography.cardTitle)
266 + .foregroundStyle(KleponColor.textPrimary)
267 +
268 + Text(collection.subtitle)
269 + .font(KleponTypography.bodySecondary)
270 + .foregroundStyle(KleponColor.textSecondary)
271 + }
272 +
273 + ScrollView(.horizontal, showsIndicators: false) {
274 + HStack(spacing: 14) {
275 + ForEach(entries) { entry in
276 + NavigationLink {
277 + GuideDetailView(entry: entry)
278 + } label: {
279 + VStack(alignment: .leading, spacing: 10) {
280 + Text(entry.title)
281 + .font(KleponTypography.cardTitle)
282 + .foregroundStyle(KleponColor.textPrimary)
283 + .lineLimit(2)
284 +
285 + Text(entry.subtitle)
286 + .font(KleponTypography.caption)
287 + .foregroundStyle(KleponColor.accentWarm)
288 + .lineLimit(2)
289 +
290 + Spacer(minLength: 0)
291 +
292 + Text(entry.summary)
293 + .font(KleponTypography.bodySecondary)
294 + .foregroundStyle(KleponColor.textSecondary)
295 + .lineLimit(3)
296 + }
297 + .padding(16)
298 + .frame(width: 220, alignment: .leading)
299 + .frame(minHeight: 160, alignment: .topLeading)
300 + .background(
301 + RoundedRectangle(cornerRadius: 22, style: .continuous)
302 + .fill(KleponColor.surfaceSecondary)
303 + )
304 + }
305 + .buttonStyle(.plain)
306 + }
307 + }
308 + }
309 + }
310 + }
311 +}
Klepon/Features/Onboarding/OnboardingView.swift new
+143
@@ -0,0 +1,143 @@
1 +import SwiftUI
2 +
3 +struct OnboardingView: View {
4 + @EnvironmentObject private var guideEngine: OndeGuideEngine
5 +
6 + let onBrowseFirst: () -> Void
7 + let onComplete: () -> Void
8 +
9 + private var privateGuideButtonTitle: String {
10 + switch guideEngine.availability {
11 + case .ready:
12 + return "Private guide ready"
13 + case .downloaded:
14 + return "Finish preparing private guide"
15 + case .failed:
16 + return "Try private guide again"
17 + default:
18 + return "Add private guide"
19 + }
20 + }
21 +
22 + var body: some View {
23 + ScrollView {
24 + VStack(alignment: .leading, spacing: 24) {
25 + Spacer(minLength: 24)
26 +
27 + Text("Klepon")
28 + .font(KleponTypography.heroTitle)
29 + .foregroundStyle(KleponColor.textPrimary)
30 +
31 + Text("Taste of Indonesia")
32 + .font(KleponTypography.screenTitle)
33 + .foregroundStyle(KleponColor.accentWarm)
34 +
35 + Text("A warm pocket guide to Indonesian dishes, ingredients, and food traditions.")
36 + .font(KleponTypography.body)
37 + .foregroundStyle(KleponColor.textSecondary)
38 +
39 + KleponCard {
40 + onboardingPoint(
41 + title: "Curated before clever",
42 + detail:
43 + "Browse thoughtful dishes and food notes first. The AI layer stays in the background.",
44 + systemImage: "fork.knife"
45 + )
46 + }
47 +
48 + KleponCard {
49 + onboardingPoint(
50 + title: "Private on your iPhone",
51 + detail:
52 + "Private answers use a one-time on-device guide download. You can browse first and add it later.",
53 + systemImage: "lock"
54 + )
55 + }
56 +
57 + KleponCard {
58 + onboardingPoint(
59 + title: "Made to feel calm",
60 + detail:
61 + "No accounts, no noisy feeds, and no generic chatbot shell. Just a clean guide you can keep close.",
62 + systemImage: "sparkles"
63 + )
64 + }
65 +
66 + KleponCard {
67 + VStack(alignment: .leading, spacing: 10) {
68 + Text("Private guide status")
69 + .font(KleponTypography.cardTitle)
70 + .foregroundStyle(KleponColor.textPrimary)
71 +
72 + Text(guideEngine.availability.title)
73 + .font(KleponTypography.bodySecondary)
74 + .foregroundStyle(
75 + guideEngine.availability.isFailure
76 + ? KleponColor.highlight : KleponColor.textSecondary)
77 +
78 + Text(guideEngine.availability.detail)
79 + .font(KleponTypography.caption)
80 + .foregroundStyle(KleponColor.textSecondary)
81 +
82 + HStack(spacing: 10) {
83 + KleponChip(
84 + title: guideEngine.estimatedDownloadDescription,
85 + icon: "arrow.down.circle")
86 + KleponChip(
87 + title: guideEngine.storageUsedDescription, icon: "internaldrive")
88 + }
89 + }
90 + }
91 + }
92 + .padding(.horizontal, 24)
93 + .padding(.top, 24)
94 + .padding(.bottom, 180)
95 + }
96 + .background(KleponColor.background.ignoresSafeArea())
97 + .safeAreaInset(edge: .bottom) {
98 + VStack(spacing: 12) {
99 + KleponActionButton(
100 + title: privateGuideButtonTitle,
101 + systemImage: "lock",
102 + isLoading: guideEngine.availability.isBusy,
103 + isDisabled: guideEngine.availability == .ready
104 + ) {
105 + Task {
106 + await guideEngine.prepareIfNeeded(
107 + forceReload: guideEngine.availability.isFailure)
108 + if guideEngine.availability == .ready {
109 + onComplete()
110 + }
111 + }
112 + }
113 +
114 + KleponActionButton(title: "Browse first", tone: .secondary) {
115 + onBrowseFirst()
116 + }
117 + }
118 + .padding(.horizontal, 24)
119 + .padding(.top, 12)
120 + .padding(.bottom, 16)
121 + .background(.ultraThinMaterial)
122 + }
123 + }
124 +
125 + private func onboardingPoint(title: String, detail: String, systemImage: String) -> some View {
126 + HStack(alignment: .top, spacing: 14) {
127 + Image(systemName: systemImage)
128 + .font(.title3)
129 + .foregroundStyle(KleponColor.accent)
130 + .frame(width: 28)
131 +
132 + VStack(alignment: .leading, spacing: 6) {
133 + Text(title)
134 + .font(KleponTypography.cardTitle)
135 + .foregroundStyle(KleponColor.textPrimary)
136 +
137 + Text(detail)
138 + .font(KleponTypography.bodySecondary)
139 + .foregroundStyle(KleponColor.textSecondary)
140 + }
141 + }
142 + }
143 +}
Klepon/Features/Saved/SavedView.swift new
+65
@@ -0,0 +1,65 @@
1 +import SwiftUI
2 +
3 +struct SavedView: View {
4 + @EnvironmentObject private var appState: AppState
5 + @EnvironmentObject private var favoritesStore: FavoritesStore
6 +
7 + private var savedEntries: [GuideEntry] {
8 + appState.contentRepository.entries.filter { favoritesStore.favoriteIDs.contains($0.id) }
9 + }
10 +
11 + var body: some View {
12 + ScrollView {
13 + VStack(alignment: .leading, spacing: 20) {
14 + if savedEntries.isEmpty {
15 + EmptyStateView(
16 + title: "Save dishes you want to revisit",
17 + message:
18 + "Build a shortlist as you browse so it is easy to come back to dishes, ingredients, and traditions later.",
19 + systemImage: "heart"
20 + )
21 + } else {
22 + SectionHeader(
23 + "Your shortlist", subtitle: "A calm place to keep what you want to remember"
24 + )
25 +
26 + ForEach(savedEntries) { entry in
27 + KleponCard {
28 + HStack(alignment: .top, spacing: 12) {
29 + NavigationLink {
30 + GuideDetailView(entry: entry)
31 + } label: {
32 + VStack(alignment: .leading, spacing: 8) {
33 + Text(entry.title)
34 + .font(KleponTypography.cardTitle)
35 + .foregroundStyle(KleponColor.textPrimary)
36 +
37 + Text(entry.summary)
38 + .font(KleponTypography.bodySecondary)
39 + .foregroundStyle(KleponColor.textSecondary)
40 + }
41 + .frame(maxWidth: .infinity, alignment: .leading)
42 + }
43 + .buttonStyle(.plain)
44 +
45 + Button {
46 + favoritesStore.toggle(entry.id)
47 + } label: {
48 + Image(systemName: "heart.slash")
49 + .foregroundStyle(KleponColor.highlight)
50 + .padding(10)
51 + .background(Circle().fill(KleponColor.surfaceSecondary))
52 + }
53 + .buttonStyle(.plain)
54 + }
55 + }
56 + }
57 + }
58 + }
59 + .padding(.horizontal, 20)
60 + .padding(.vertical, 16)
61 + }
62 + .background(KleponColor.background.ignoresSafeArea())
63 + .navigationTitle("Saved")
64 + }
65 +}
Klepon/Features/Search/SearchView.swift new
+147
@@ -0,0 +1,147 @@
1 +import SwiftUI
2 +
3 +struct SearchView: View {
4 + @EnvironmentObject private var appState: AppState
5 + @EnvironmentObject private var recentSearchStore: RecentSearchStore
6 +
7 + @State private var query = ""
8 +
9 + private var results: [GuideEntry] {
10 + appState.searchService.results(for: query)
11 + }
12 +
13 + private var recentEntries: [GuideEntry] {
14 + appState.recentlyViewedStore.entryIDs.compactMap {
15 + appState.contentRepository.entry(id: $0)
16 + }
17 + }
18 +
19 + var body: some View {
20 + ScrollView {
21 + VStack(alignment: .leading, spacing: 20) {
22 + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
23 + if !recentSearchStore.queries.isEmpty {
24 + VStack(alignment: .leading, spacing: 12) {
25 + SectionHeader("Recent searches")
26 +
27 + LazyVGrid(
28 + columns: [GridItem(.adaptive(minimum: 120), spacing: 10)],
29 + alignment: .leading,
30 + spacing: 10
31 + ) {
32 + ForEach(recentSearchStore.queries, id: \.self) { recentQuery in
33 + Button {
34 + query = recentQuery
35 + } label: {
36 + KleponChip(title: recentQuery, icon: "clock")
37 + }
38 + .buttonStyle(.plain)
39 + }
40 + }
41 + }
42 + }
43 +
44 + SectionHeader(
45 + "Start with something familiar",
46 + subtitle: "Try a dish, ingredient, or food tradition")
47 +
48 + ForEach(appState.searchService.suggestions()) { entry in
49 + NavigationLink {
50 + GuideDetailView(entry: entry)
51 + } label: {
52 + SearchResultCard(entry: entry)
53 + }
54 + .simultaneousGesture(
55 + TapGesture().onEnded {
56 + appState.recentSearchStore.record(entry.title)
57 + }
58 + )
59 + .buttonStyle(.plain)
60 + }
61 +
62 + if !recentEntries.isEmpty {
63 + VStack(alignment: .leading, spacing: 12) {
64 + SectionHeader("Recently viewed", subtitle: "Pick up where you left off")
65 +
66 + ForEach(Array(recentEntries.prefix(3))) { entry in
67 + NavigationLink {
68 + GuideDetailView(entry: entry)
69 + } label: {
70 + SearchResultCard(entry: entry)
71 + }
72 + .buttonStyle(.plain)
73 + }
74 + }
75 + }
76 + } else if results.isEmpty {
77 + EmptyStateView(
78 + title: "No exact match yet",
79 + message:
80 + "Try another spelling or open a dish and ask a private follow-up question from there.",
81 + systemImage: "magnifyingglass"
82 + )
83 + } else {
84 + SectionHeader("Results", subtitle: "Curated matches from your local guide")
85 +
86 + ForEach(results) { entry in
87 + NavigationLink {
88 + GuideDetailView(entry: entry)
89 + } label: {
90 + SearchResultCard(entry: entry)
91 + }
92 + .simultaneousGesture(
93 + TapGesture().onEnded {
94 + appState.recentSearchStore.record(query)
95 + }
96 + )
97 + .buttonStyle(.plain)
98 + }
99 + }
100 + }
101 + .padding(.horizontal, 20)
102 + .padding(.vertical, 16)
103 + }
104 + .background(KleponColor.background.ignoresSafeArea())
105 + .navigationTitle("Search")
106 + .searchable(text: $query, prompt: "Try rendang, sambal, or klepon")
107 + .onSubmit(of: .search) {
108 + appState.recentSearchStore.record(query)
109 + }
110 + }
111 +}
112 +
113 +private struct SearchResultCard: View {
114 + let entry: GuideEntry
115 +
116 + var body: some View {
117 + KleponCard {
118 + VStack(alignment: .leading, spacing: 10) {
119 + HStack {
120 + Text(entry.title)
121 + .font(KleponTypography.cardTitle)
122 + .foregroundStyle(KleponColor.textPrimary)
123 +
124 + Spacer(minLength: 12)
125 +
126 + Label(entry.kind.displayTitle, systemImage: entry.kind.symbolName)
127 + .font(KleponTypography.caption)
128 + .foregroundStyle(KleponColor.accentWarm)
129 + }
130 +
131 + Text(entry.subtitle)
132 + .font(KleponTypography.bodySecondary)
133 + .foregroundStyle(KleponColor.textSecondary)
134 +
135 + if !entry.tasteNotes.isEmpty {
136 + ScrollView(.horizontal, showsIndicators: false) {
137 + HStack(spacing: 8) {
138 + ForEach(Array(entry.tasteNotes.prefix(3)), id: \.self) { note in
139 + KleponChip(title: note)
140 + }
141 + }
142 + }
143 + }
144 + }
145 + }
146 + }
147 +}
Klepon/Features/Settings/SettingsView.swift new
+180
@@ -0,0 +1,180 @@
1 +import SwiftUI
2 +
3 +struct SettingsView: View {
4 + @Environment(\.dismiss) private var dismiss
5 + @EnvironmentObject private var appState: AppState
6 + @EnvironmentObject private var favoritesStore: FavoritesStore
7 + @EnvironmentObject private var recentSearchStore: RecentSearchStore
8 + @EnvironmentObject private var guideEngine: OndeGuideEngine
9 +
10 + private var versionString: String {
11 + let version =
12 + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
13 + ?? "1.0"
14 + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
15 + return "Version \(version) (\(build))"
16 + }
17 +
18 + var body: some View {
19 + ScrollView {
20 + VStack(alignment: .leading, spacing: 18) {
21 + KleponCard {
22 + VStack(alignment: .leading, spacing: 12) {
23 + Text("Private guide")
24 + .font(KleponTypography.cardTitle)
25 + .foregroundStyle(KleponColor.textPrimary)
26 +
27 + Text(guideEngine.availability.title)
28 + .font(KleponTypography.bodySecondary)
29 + .foregroundStyle(
30 + guideEngine.availability.isFailure
31 + ? KleponColor.highlight : KleponColor.textSecondary)
32 +
33 + Text(guideEngine.availability.detail)
34 + .font(KleponTypography.caption)
35 + .foregroundStyle(KleponColor.textSecondary)
36 +
37 + HStack(spacing: 10) {
38 + KleponChip(
39 + title: guideEngine.estimatedDownloadDescription,
40 + icon: "arrow.down.circle")
41 + KleponChip(
42 + title: guideEngine.storageUsedDescription, icon: "internaldrive")
43 + }
44 +
45 + KleponActionButton(
46 + title: guideEngine.availability.actionTitle,
47 + systemImage: guideEngine.availability == .ready
48 + ? "checkmark.circle.fill" : "arrow.down.circle",
49 + isLoading: guideEngine.availability.isBusy,
50 + isDisabled: guideEngine.availability == .ready
51 + ) {
52 + Task {
53 + await guideEngine.prepareIfNeeded(
54 + forceReload: guideEngine.availability.isFailure)
55 + }
56 + }
57 +
58 + if guideEngine.storageUsedDescription != "Not downloaded yet" {
59 + KleponActionButton(
60 + title: "Remove private guide from this iPhone",
61 + systemImage: "trash",
62 + tone: .secondary
63 + ) {
64 + Task {
65 + await guideEngine.removePrivateGuide()
66 + }
67 + }
68 + }
69 + }
70 + }
71 +
72 + KleponCard {
73 + VStack(alignment: .leading, spacing: 12) {
74 + Text("App info")
75 + .font(KleponTypography.cardTitle)
76 + .foregroundStyle(KleponColor.textPrimary)
77 +
78 + KleponMetadataRow(title: "App", value: "Klepon: Taste of Indonesia")
79 + KleponMetadataRow(title: "Publisher", value: "Splitfire AB")
80 + KleponMetadataRow(title: "Build", value: versionString)
81 + }
82 + }
83 +
84 + KleponCard {
85 + VStack(alignment: .leading, spacing: 10) {
86 + Text("Privacy")
87 + .font(KleponTypography.cardTitle)
88 + .foregroundStyle(KleponColor.textPrimary)
89 +
90 + Text(
91 + "Browseable food content is bundled with the app. Private answers use a one-time on-device guide download when you choose to enable it."
92 + )
93 + .font(KleponTypography.bodySecondary)
94 + .foregroundStyle(KleponColor.textSecondary)
95 + }
96 + }
97 +
98 + KleponCard {
99 + VStack(alignment: .leading, spacing: 12) {
100 + Text("Browsing data")
101 + .font(KleponTypography.cardTitle)
102 + .foregroundStyle(KleponColor.textPrimary)
103 +
104 + KleponMetadataRow(
105 + title: "Saved", value: "\(favoritesStore.favoriteIDs.count) items")
106 + KleponMetadataRow(
107 + title: "Recent searches",
108 + value: "\(recentSearchStore.queries.count) items")
109 + KleponMetadataRow(
110 + title: "Recently viewed",
111 + value: "\(appState.recentlyViewedStore.entryIDs.count) items")
112 +
113 + if !recentSearchStore.queries.isEmpty {
114 + KleponActionButton(title: "Clear recent searches", tone: .secondary) {
115 + recentSearchStore.clear()
116 + }
117 + }
118 +
119 + if !appState.recentlyViewedStore.entryIDs.isEmpty {
120 + KleponActionButton(title: "Clear recently viewed", tone: .secondary) {
121 + appState.recentlyViewedStore.clear()
122 + }
123 + }
124 +
125 + if !favoritesStore.favoriteIDs.isEmpty {
126 + KleponActionButton(title: "Clear saved list", tone: .secondary) {
127 + favoritesStore.clear()
128 + }
129 + }
130 + }
131 + }
132 +
133 + KleponCard {
134 + VStack(alignment: .leading, spacing: 12) {
135 + Text("Onboarding")
136 + .font(KleponTypography.cardTitle)
137 + .foregroundStyle(KleponColor.textPrimary)
138 +
139 + Text(
140 + "If you want to see Klepon’s welcome and privacy intro again, you can show it on the next launch path."
141 + )
142 + .font(KleponTypography.bodySecondary)
143 + .foregroundStyle(KleponColor.textSecondary)
144 +
145 + KleponActionButton(title: "Show welcome again", tone: .secondary) {
146 + appState.hasCompletedOnboarding = false
147 + dismiss()
148 + }
149 + }
150 + }
151 +
152 + KleponCard {
153 + VStack(alignment: .leading, spacing: 10) {
154 + Text("Technology")
155 + .font(KleponTypography.cardTitle)
156 + .foregroundStyle(KleponColor.textPrimary)
157 +
158 + Text(
159 + "Klepon’s private answer layer is powered on-device by Onde Inference. The technology stays in the background so the product can feel like a guide first."
160 + )
161 + .font(KleponTypography.bodySecondary)
162 + .foregroundStyle(KleponColor.textSecondary)
163 + }
164 + }
165 + }
166 + .padding(.horizontal, 20)
167 + .padding(.vertical, 16)
168 + }
169 + .background(KleponColor.background.ignoresSafeArea())
170 + .navigationTitle("Settings")
171 + .toolbar {
172 + ToolbarItem(placement: .topBarTrailing) {
173 + Button("Done") {
174 + dismiss()
175 + }
176 + .tint(KleponColor.accent)
177 + }
178 + }
179 + }
180 +}
Klepon/Klepon.entitlements new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="UTF-8" ?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 +<plist version="1.0">
5 +<dict>
6 + <key>com.apple.developer.kernel.increased-memory-limit</key>
7 + <true />
8 + <key>com.apple.security.application-groups</key>
9 + <array>
10 + <string>group.com.ondeinference.apps</string>
11 + </array>
12 +</dict>
13 +</plist>
Klepon/Resources/Content/collections.json new
+59
@@ -0,0 +1,59 @@
1 +[
2 + {
3 + "id": "start-here",
4 + "title": "Start here",
5 + "subtitle": "Friendly first dishes for a warm introduction",
6 + "entryIDs": ["nasi-goreng", "sate", "gado-gado", "klepon", "bakso"]
7 + },
8 + {
9 + "id": "street-side-favorites",
10 + "title": "Street-side favorites",
11 + "subtitle": "Fast, fragrant, and full of personality",
12 + "entryIDs": [
13 + "street-food",
14 + "nasi-goreng",
15 + "sate",
16 + "pempek",
17 + "martabak-manis",
18 + "martabak-telur",
19 + "bakso"
20 + ]
21 + },
22 + {
23 + "id": "bold-and-savory",
24 + "title": "Bold and savory",
25 + "subtitle": "Richer dishes and the flavors that sharpen them",
26 + "entryIDs": [
27 + "rendang",
28 + "rawon",
29 + "soto-ayam",
30 + "sambal",
31 + "kecap-manis",
32 + "ayam-penyet"
33 + ]
34 + },
35 + {
36 + "id": "sweet-and-comforting",
37 + "title": "Sweet and comforting",
38 + "subtitle": "Palm sugar, coconut, and softer textures",
39 + "entryIDs": [
40 + "klepon",
41 + "martabak-manis",
42 + "festive-feasts",
43 + "coconut-milk",
44 + "gudeg"
45 + ]
46 + },
47 + {
48 + "id": "breakfast-and-comfort",
49 + "title": "Breakfast and comfort",
50 + "subtitle": "Gentler dishes that still feel layered and local",
51 + "entryIDs": ["bubur-ayam", "soto-ayam", "bakso", "tempeh"]
52 + },
53 + {
54 + "id": "regional-icons",
55 + "title": "Regional icons",
56 + "subtitle": "A few flavors strongly tied to place",
57 + "entryIDs": ["rendang", "pempek", "gudeg", "rawon", "gado-gado"]
58 + }
59 +]
Klepon/Resources/Content/guide_entries.json new
+502
@@ -0,0 +1,502 @@
1 +[
2 + {
3 + "id": "klepon",
4 + "kind": "dish",
5 + "title": "Klepon",
6 + "subtitle": "Sweet rice cake with palm sugar and coconut",
7 + "summary": "A small glutinous rice cake filled with melted palm sugar and rolled in fresh coconut.",
8 + "story": "Klepon is one of the most recognizable Indonesian sweet bites: soft, chewy, grassy-green from pandan or suji, and surprising when the warm palm sugar center spills out. It feels comforting, familiar, and often a little playful to eat.",
9 + "tasteNotes": ["chewy", "sweet", "coconut", "palm sugar"],
10 + "highlights": [
11 + "Best eaten in one bite so the molten center does not spill everywhere",
12 + "Often grouped with market snacks known as kue basah",
13 + "Fresh coconut makes the texture feel soft rather than dry"
14 + ],
15 + "region": "Common across Java and beyond",
16 + "aliases": ["onde-onde kecil", "pandan rice cake"],
17 + "tags": ["dessert", "snack", "market snack", "pandan"],
18 + "relatedIDs": ["festive-feasts", "coconut-milk", "martabak-manis"],
19 + "suggestedQuestions": [
20 + "What does klepon taste like?",
21 + "Is klepon always green?",
22 + "When do people usually eat klepon?"
23 + ],
24 + "imageName": null,
25 + "isFeatured": true
26 + },
27 + {
28 + "id": "rendang",
29 + "kind": "dish",
30 + "title": "Rendang",
31 + "subtitle": "Slow-cooked beef in a rich spiced coconut base",
32 + "summary": "A deeply savory beef dish cooked slowly with coconut milk, chilies, and aromatics until concentrated and dark.",
33 + "story": "Rendang is closely associated with Minangkabau cooking from West Sumatra. It is known for patience and depth: the sauce cooks down gradually until the spices cling tightly to the meat, creating a dish that feels intense, celebratory, and layered rather than simply hot.",
34 + "tasteNotes": ["savory", "spiced", "rich", "deeply aromatic"],
35 + "highlights": [
36 + "The flavor comes from reduction and time, not just chili heat",
37 + "Texture and moisture can vary from saucier to quite dry",
38 + "Often seen at festive meals and special gatherings"
39 + ],
40 + "region": "West Sumatra and Minangkabau cuisine",
41 + "aliases": ["beef rendang"],
42 + "tags": ["beef", "coconut milk", "celebration", "sumatra"],
43 + "relatedIDs": ["sambal", "festive-feasts", "rawon", "coconut-milk"],
44 + "suggestedQuestions": [
45 + "Is rendang always spicy?",
46 + "Why is rendang so dark and rich?",
47 + "What should I try if I like rendang?"
48 + ],
49 + "imageName": null,
50 + "isFeatured": true
51 + },
52 + {
53 + "id": "nasi-goreng",
54 + "kind": "dish",
55 + "title": "Nasi goreng",
56 + "subtitle": "Indonesia’s beloved fried rice",
57 + "summary": "A savory fried rice dish usually built from rice, aromatics, sweet soy sauce, and whatever proteins or vegetables are on hand.",
58 + "story": "Nasi goreng is one of the most approachable Indonesian dishes because it feels familiar while still tasting distinctively local. Kecap manis often gives it a darker color and round sweetness, while toppings like egg, crackers, pickles, or sambal shape the final experience.",
59 + "tasteNotes": ["savory", "smoky", "slightly sweet", "comforting"],
60 + "highlights": [
61 + "A plate can feel homey or street-side depending on style",
62 + "Sweet soy sauce often gives the dish its signature depth",
63 + "Commonly served with fried egg, crackers, or sambal"
64 + ],
65 + "region": "Popular across Indonesia",
66 + "aliases": ["fried rice indonesia"],
67 + "tags": ["rice", "street food", "comfort food", "quick meal"],
68 + "relatedIDs": ["kecap-manis", "sambal", "street-food", "bakso"],
69 + "suggestedQuestions": [
70 + "What makes nasi goreng different from other fried rice?",
71 + "Is nasi goreng sweet or savory?",
72 + "What is usually served with nasi goreng?"
73 + ],
74 + "imageName": null,
75 + "isFeatured": true
76 + },
77 + {
78 + "id": "gado-gado",
79 + "kind": "dish",
80 + "title": "Gado-gado",
81 + "subtitle": "Mixed vegetables with peanut sauce",
82 + "summary": "A composed dish of vegetables, tofu, tempeh, egg, and crackers dressed in a rich peanut sauce.",
83 + "story": "Gado-gado can look simple at first, but the balance matters: crisp and soft textures, blanched and raw elements, and a peanut sauce that can lean sweet, savory, or spicy depending on the maker. It is often one of the easiest dishes for newcomers to enjoy immediately.",
84 + "tasteNotes": ["nutty", "savory", "fresh", "slightly sweet"],
85 + "highlights": [
86 + "Peanut sauce texture and sweetness vary widely",
87 + "Tofu, tempeh, egg, and lontong often make it filling enough for a full meal",
88 + "Crackers add crunch and contrast"
89 + ],
90 + "region": "Common in Java, with many local styles",
91 + "aliases": ["indonesian salad"],
92 + "tags": ["vegetables", "peanut sauce", "tofu", "tempeh"],
93 + "relatedIDs": ["tempeh", "sambal", "street-food", "coconut-milk"],
94 + "suggestedQuestions": [
95 + "Is gado-gado vegetarian?",
96 + "What does gado-gado sauce taste like?",
97 + "How is gado-gado different from pecel?"
98 + ],
99 + "imageName": null,
100 + "isFeatured": true
101 + },
102 + {
103 + "id": "sate",
104 + "kind": "dish",
105 + "title": "Sate",
106 + "subtitle": "Skewered grilled meat with bold sauces",
107 + "summary": "Small skewers of grilled meat, often served with peanut sauce, sweet soy, rice cakes, or sambal.",
108 + "story": "Sate is one of the most recognizable Indonesian street and dinner dishes. The details change by region and type of meat, but the core appeal is easy to understand: smoke from the grill, concentrated seasoning, and a dipping sauce that can tilt nutty, sweet, savory, or spicy.",
109 + "tasteNotes": ["smoky", "savory", "charred", "nutty"],
110 + "highlights": [
111 + "Different regions have distinct satay styles and sauces",
112 + "Peanut sauce is common but not universal",
113 + "Often served with rice cake, sliced shallots, and chili"
114 + ],
115 + "region": "Found across Indonesia with many local styles",
116 + "aliases": ["satay"],
117 + "tags": ["grilled", "street food", "skewers", "peanut sauce"],
118 + "relatedIDs": ["kecap-manis", "sambal", "street-food", "ayam-penyet"],
119 + "suggestedQuestions": [
120 + "What sauce usually comes with sate?",
121 + "What makes Indonesian sate special?",
122 + "What should I try with sate?"
123 + ],
124 + "imageName": null,
125 + "isFeatured": true
126 + },
127 + {
128 + "id": "soto-ayam",
129 + "kind": "dish",
130 + "title": "Soto ayam",
131 + "subtitle": "Comforting chicken soup with bright aromatics",
132 + "summary": "A fragrant chicken soup built with turmeric, aromatics, noodles or rice, and assorted toppings.",
133 + "story": "Soto ayam is one of those dishes that feels generous and restorative. It can be light and clear or a little richer depending on the style, but it usually carries a bright yellow tone from turmeric and a layered aroma from herbs, lime, fried shallots, and broth.",
134 + "tasteNotes": ["brothy", "fragrant", "comforting", "bright"],
135 + "highlights": [
136 + "Usually finished with toppings added at the table",
137 + "Texture depends on whether it is served with rice, noodles, or both",
138 + "Lime and sambal let people tune the final flavor"
139 + ],
140 + "region": "Popular across Indonesia with many variations",
141 + "aliases": ["indonesian chicken soup"],
142 + "tags": ["soup", "turmeric", "comfort food", "chicken"],
143 + "relatedIDs": ["sambal", "street-food", "bubur-ayam"],
144 + "suggestedQuestions": [
145 + "Is soto ayam spicy?",
146 + "What is usually inside soto ayam?",
147 + "When do people eat soto ayam?"
148 + ],
149 + "imageName": null,
150 + "isFeatured": false
151 + },
152 + {
153 + "id": "pempek",
154 + "kind": "dish",
155 + "title": "Pempek",
156 + "subtitle": "Chewy fish cakes with sweet-sour cuko sauce",
157 + "summary": "A fish-based cake or dumpling, usually served with a dark, tangy, spicy cuko sauce.",
158 + "story": "Pempek is strongly associated with Palembang in South Sumatra. Its appeal comes from contrast: chewy fish cakes, crisp fried edges on some styles, and a bold sauce that mixes sweetness, sourness, and chili heat in a way that feels sharp and memorable.",
159 + "tasteNotes": ["chewy", "savory", "tangy", "sweet-sour"],
160 + "highlights": [
161 + "Cuko sauce is a big part of the identity of the dish",
162 + "There are many shapes and fillings, including egg-filled versions",
163 + "Texture matters as much as flavor"
164 + ],
165 + "region": "Palembang, South Sumatra",
166 + "aliases": ["palembang fish cake"],
167 + "tags": ["fish", "street food", "south sumatra", "snack"],
168 + "relatedIDs": ["street-food", "sambal", "rawon"],
169 + "suggestedQuestions": [
170 + "What does pempek sauce taste like?",
171 + "Is pempek more like a snack or a meal?",
172 + "What makes pempek different from fish balls?"
173 + ],
174 + "imageName": null,
175 + "isFeatured": false
176 + },
177 + {
178 + "id": "bakso",
179 + "kind": "dish",
180 + "title": "Bakso",
181 + "subtitle": "Meatball soup that feels deeply everyday",
182 + "summary": "A popular Indonesian meatball soup with springy meatballs, clear broth, noodles, and customizable toppings.",
183 + "story": "Bakso is one of those dishes that feels instantly familiar yet distinctly local. The broth is usually straightforward and comforting, while the texture of the meatballs matters a lot: springy, bouncy, and satisfying in a way that makes the whole bowl feel casual and dependable.",
184 + "tasteNotes": ["brothy", "savory", "springy", "comforting"],
185 + "highlights": [
186 + "Texture is central to the appeal of bakso",
187 + "Vendors and stalls often offer lots of topping variations",
188 + "A bowl can feel like a snack, lunch, or late-day comfort meal"
189 + ],
190 + "region": "Found across Indonesia",
191 + "aliases": ["indonesian meatball soup"],
192 + "tags": ["soup", "meatballs", "street food", "comfort"],
193 + "relatedIDs": ["soto-ayam", "street-food", "bubur-ayam"],
194 + "suggestedQuestions": [
195 + "What is bakso like to eat?",
196 + "Why do people love bakso so much?",
197 + "Is bakso more of a snack or a full meal?"
198 + ],
199 + "imageName": null,
200 + "isFeatured": true
201 + },
202 + {
203 + "id": "gudeg",
204 + "kind": "dish",
205 + "title": "Gudeg",
206 + "subtitle": "Young jackfruit stew with sweet, savory depth",
207 + "summary": "A slow-cooked young jackfruit dish often associated with Yogyakarta, usually served with rice and savory side dishes.",
208 + "story": "Gudeg surprises many first-time eaters because its sweetness is more pronounced than they expect from a main dish. The jackfruit cooks down into a soft, fibrous texture, while the full plate around it usually adds contrast through eggs, sambal, or richer savory components.",
209 + "tasteNotes": ["sweet-savory", "soft", "rich", "aromatic"],
210 + "highlights": [
211 + "Often understood best as part of a full plate rather than by itself",
212 + "The sweetness is part of the identity, not an accident",
213 + "Texture is tender and stewed rather than crisp or sharp"
214 + ],
215 + "region": "Yogyakarta and Central Java",
216 + "aliases": ["jackfruit stew"],
217 + "tags": ["jackfruit", "java", "stewed", "sweet-savory"],
218 + "relatedIDs": ["festive-feasts", "tempeh", "coconut-milk"],
219 + "suggestedQuestions": [
220 + "Why is gudeg sweet?",
221 + "What is usually served with gudeg?",
222 + "Would I like gudeg if I usually prefer savory food?"
223 + ],
224 + "imageName": null,
225 + "isFeatured": false
226 + },
227 + {
228 + "id": "rawon",
229 + "kind": "dish",
230 + "title": "Rawon",
231 + "subtitle": "Dark beef soup with earthy depth",
232 + "summary": "A blackish beef soup known for its deep color and earthy richness, especially associated with East Java.",
233 + "story": "Rawon stands out immediately because of its color, which comes from keluak. The bowl often feels lighter than it looks at first glance, but the flavor is unmistakably deep, earthy, and savory. It is the kind of dish that feels serious without being heavy in a creamy way.",
234 + "tasteNotes": ["earthy", "savory", "deep", "brothy"],
235 + "highlights": [
236 + "Its dark color is one of the most recognizable parts of the dish",
237 + "The broth is rich in flavor but still soup-like and drinkable",
238 + "Often served with rice and small accompaniments for contrast"
239 + ],
240 + "region": "East Java",
241 + "aliases": ["black beef soup"],
242 + "tags": ["beef", "soup", "east java", "keluak"],
243 + "relatedIDs": ["rendang", "soto-ayam", "street-food"],
244 + "suggestedQuestions": [
245 + "Why is rawon so dark?",
246 + "What does rawon taste like?",
247 + "How is rawon different from other beef soups?"
248 + ],
249 + "imageName": null,
250 + "isFeatured": false
251 + },
252 + {
253 + "id": "martabak-manis",
254 + "kind": "dish",
255 + "title": "Martabak manis",
256 + "subtitle": "Thick griddled pancake filled with sweet toppings",
257 + "summary": "A thick, spongy sweet griddled cake folded over generous fillings like chocolate, peanuts, cheese, or condensed milk.",
258 + "story": "Martabak manis is rich, indulgent, and unapologetically generous. It is usually eaten by sharing slices, and the experience is as much about abundance as flavor: buttery edges, a soft honeycombed center, and fillings that make it feel halfway between street snack and celebration treat.",
259 + "tasteNotes": ["sweet", "buttery", "soft", "indulgent"],
260 + "highlights": [
261 + "Often bought to share rather than eat alone",
262 + "Texture matters: soft interior, richer edges, lots of filling",
263 + "Toppings vary a lot, from classic chocolate-peanut to newer combinations"
264 + ],
265 + "region": "Popular across Indonesia",
266 + "aliases": ["bright moon pancake", "terang bulan"],
267 + "tags": ["dessert", "street food", "sweet", "shared snack"],
268 + "relatedIDs": ["klepon", "street-food", "festive-feasts"],
269 + "suggestedQuestions": [
270 + "What does martabak manis feel like to eat?",
271 + "Why is martabak manis so rich?",
272 + "Is martabak manis a snack or a dessert?"
273 + ],
274 + "imageName": null,
275 + "isFeatured": true
276 + },
277 + {
278 + "id": "martabak-telur",
279 + "kind": "dish",
280 + "title": "Martabak telur",
281 + "subtitle": "Savory stuffed pancake with egg and meat",
282 + "summary": "A crisp, griddled savory martabak filled with egg, aromatics, and often minced meat.",
283 + "story": "Martabak telur feels completely different from its sweet counterpart. It is thinner, crisper, more savory, and usually served in cut squares with pickles or sauce. The pleasure comes from the contrast between the crisp wrapper and the warm, seasoned filling.",
284 + "tasteNotes": ["savory", "crisp", "rich", "aromatic"],
285 + "highlights": [
286 + "The sweet and savory martabak styles are different dishes, not small variations",
287 + "Best known for its crispy exterior and soft filling",
288 + "Often paired with condiments that brighten the richness"
289 + ],
290 + "region": "Popular across Indonesian cities and street-food settings",
291 + "aliases": ["savory martabak"],
292 + "tags": ["street food", "egg", "crispy", "savory snack"],
293 + "relatedIDs": ["street-food", "kecap-manis", "sambal"],
294 + "suggestedQuestions": [
295 + "How is martabak telur different from martabak manis?",
296 + "Is martabak telur a meal or a snack?",
297 + "What usually comes with martabak telur?"
298 + ],
299 + "imageName": null,
300 + "isFeatured": false
301 + },
302 + {
303 + "id": "ayam-penyet",
304 + "kind": "dish",
305 + "title": "Ayam penyet",
306 + "subtitle": "Fried chicken with sambal and crushed textures",
307 + "summary": "A fried chicken dish usually served with sambal, rice, and crunchy or fresh sides.",
308 + "story": "Ayam penyet is all about contrast: crackly fried chicken, assertive sambal, plain rice, and side elements that make the plate feel complete rather than just spicy. The dish often reads as bold and straightforward, but the balance between crispness, heat, and relief matters.",
309 + "tasteNotes": ["crispy", "spicy", "savory", "bold"],
310 + "highlights": [
311 + "Sambal often drives the identity of the plate",
312 + "Crisp texture and heat work together with rice for balance",
313 + "Popular as a satisfying everyday restaurant or warung meal"
314 + ],
315 + "region": "Popular across Indonesia and neighboring regions",
316 + "aliases": ["smashed fried chicken"],
317 + "tags": ["chicken", "sambal", "fried", "rice plate"],
318 + "relatedIDs": ["sambal", "sate", "tempeh"],
319 + "suggestedQuestions": [
320 + "Is ayam penyet mainly about the sambal?",
321 + "What does ayam penyet usually come with?",
322 + "Would I like ayam penyet if I like fried chicken?"
323 + ],
324 + "imageName": null,
325 + "isFeatured": false
326 + },
327 + {
328 + "id": "bubur-ayam",
329 + "kind": "dish",
330 + "title": "Bubur ayam",
331 + "subtitle": "Savory rice porridge with layered toppings",
332 + "summary": "A savory chicken rice porridge topped with crunchy, soft, and aromatic garnishes.",
333 + "story": "Bubur ayam is a comfort dish that depends heavily on toppings and final balance. The porridge itself is soft and mild, while fried shallots, chicken, crackers, soy sauce, scallions, and sambal build contrast. It often feels like a quiet breakfast that still carries lots of texture.",
334 + "tasteNotes": ["soft", "savory", "comforting", "layered"],
335 + "highlights": [
336 + "The toppings matter just as much as the porridge itself",
337 + "Often associated with breakfast or a gentle start to the day",
338 + "Can be customized from very mild to much bolder"
339 + ],
340 + "region": "Popular across Indonesia with regional preferences",
341 + "aliases": ["chicken rice porridge"],
342 + "tags": ["breakfast", "porridge", "chicken", "comfort food"],
343 + "relatedIDs": ["soto-ayam", "bakso", "street-food"],
344 + "suggestedQuestions": [
345 + "Why do people love bubur ayam for breakfast?",
346 + "Is bubur ayam bland or flavorful?",
347 + "What toppings usually go on bubur ayam?"
348 + ],
349 + "imageName": null,
350 + "isFeatured": false
351 + },
352 + {
353 + "id": "tempeh",
354 + "kind": "ingredient",
355 + "title": "Tempeh",
356 + "subtitle": "Fermented soybean cake with a nutty bite",
357 + "summary": "A firm fermented soybean product that can be fried, stewed, grilled, or added into many everyday dishes.",
358 + "story": "Tempeh is one of Indonesia’s most important everyday ingredients. It is prized not because it is flashy, but because it is versatile, satisfying, and deeply embedded in ordinary cooking. The flavor is nutty and savory, and the texture can go from soft to crisp depending on how it is prepared.",
359 + "tasteNotes": ["nutty", "savory", "earthy", "firm"],
360 + "highlights": [
361 + "Often eaten by itself, not just as a supporting ingredient",
362 + "Frying can make the edges crisp while keeping the inside tender",
363 + "A good gateway ingredient for understanding everyday Indonesian cooking"
364 + ],
365 + "region": "Especially associated with Java, but used widely",
366 + "aliases": ["tempe"],
367 + "tags": ["soybeans", "fermented", "protein", "everyday cooking"],
368 + "relatedIDs": ["gado-gado", "street-food", "ayam-penyet"],
369 + "suggestedQuestions": [
370 + "What does tempeh taste like?",
371 + "How is tempeh different from tofu?",
372 + "Where might I see tempeh in Indonesian food?"
373 + ],
374 + "imageName": null,
375 + "isFeatured": false
376 + },
377 + {
378 + "id": "sambal",
379 + "kind": "ingredient",
380 + "title": "Sambal",
381 + "subtitle": "Chile-based condiment with many personalities",
382 + "summary": "A broad family of Indonesian chile condiments that can be fresh, cooked, smoky, bright, sweet, or deeply savory.",
383 + "story": "Sambal is less one thing than a whole category of flavor. Some versions are raw and bright, some are cooked down and rounded, some are fiery, and others balance sweetness, shrimp paste, tomato, or lime. It is often the detail that turns a familiar plate into something unmistakably Indonesian.",
384 + "tasteNotes": ["spicy", "bright", "savory", "variable"],
385 + "highlights": [
386 + "Heat level can vary dramatically",
387 + "Different sambals serve different roles at the table",
388 + "A small spoonful can change the whole dish"
389 + ],
390 + "region": "Used across Indonesia with many regional styles",
391 + "aliases": ["chili paste"],
392 + "tags": ["condiment", "chili", "table sauce", "spicy"],
393 + "relatedIDs": ["rendang", "nasi-goreng", "sate", "ayam-penyet"],
394 + "suggestedQuestions": [
395 + "Is all sambal extremely spicy?",
396 + "Why are there so many kinds of sambal?",
397 + "What dishes often come with sambal?"
398 + ],
399 + "imageName": null,
400 + "isFeatured": false
401 + },
402 + {
403 + "id": "kecap-manis",
404 + "kind": "ingredient",
405 + "title": "Kecap manis",
406 + "subtitle": "Sweet soy sauce with caramel depth",
407 + "summary": "A thick, sweet Indonesian soy sauce that adds gloss, color, and rounded savory sweetness to many dishes.",
408 + "story": "Kecap manis is one of those pantry ingredients that quietly defines flavor. It can make fried rice darker and more fragrant, give grilled dishes a glossy sweetness, or soften sharper edges in a sauce. The sweetness is unmistakable, but it usually works together with savory depth rather than sitting on top of the food.",
409 + "tasteNotes": ["sweet", "savory", "caramelized", "rounded"],
410 + "highlights": [
411 + "Often thicker than standard soy sauce",
412 + "A key reason many dishes have a sweet-savory balance",
413 + "Used both in cooking and at the table"
414 + ],
415 + "region": "Used widely across Indonesia",
416 + "aliases": ["sweet soy sauce"],
417 + "tags": ["condiment", "soy sauce", "sweet-savory", "pantry"],
418 + "relatedIDs": ["nasi-goreng", "sate", "martabak-telur"],
419 + "suggestedQuestions": [
420 + "What does kecap manis add to a dish?",
421 + "Is kecap manis used in nasi goreng?",
422 + "How is kecap manis different from soy sauce?"
423 + ],
424 + "imageName": null,
425 + "isFeatured": false
426 + },
427 + {
428 + "id": "coconut-milk",
429 + "kind": "ingredient",
430 + "title": "Coconut milk",
431 + "subtitle": "Creamy base that can feel rich, soft, or fragrant",
432 + "summary": "A foundational ingredient that adds body, fragrance, and richness to both savory and sweet Indonesian dishes.",
433 + "story": "Coconut milk appears so often in Indonesian food because it can do many jobs at once. It can soften spicy edges, create richness without dairy, and give both desserts and savory dishes a fuller, rounder feel. Sometimes it dominates; other times it simply makes everything feel more complete.",
434 + "tasteNotes": ["creamy", "fragrant", "rich", "softening"],
435 + "highlights": [
436 + "Used across sweets, curries, soups, and braises",
437 + "Can make bold spice feel more rounded rather than weaker",
438 + "Important for texture as much as flavor"
439 + ],
440 + "region": "Used widely across Indonesia",
441 + "aliases": ["santan"],
442 + "tags": ["ingredient", "richness", "dessert", "savory base"],
443 + "relatedIDs": ["klepon", "rendang", "gudeg"],
444 + "suggestedQuestions": [
445 + "Why is coconut milk so common in Indonesian food?",
446 + "Does coconut milk make a dish taste sweet?",
447 + "What dishes rely heavily on coconut milk?"
448 + ],
449 + "imageName": null,
450 + "isFeatured": false
451 + },
452 + {
453 + "id": "street-food",
454 + "kind": "tradition",
455 + "title": "Street food culture",
456 + "subtitle": "Everyday meals, snacks, smoke, and quick comfort",
457 + "summary": "A huge part of Indonesian food culture built around vendors, market snacks, portable meals, and dishes made to be eaten fresh and fast.",
458 + "story": "Street food in Indonesia is not just about convenience. It is about rhythm, routine, and local character: breakfast soups, afternoon snacks, skewers at dusk, market sweets, and dishes tuned to be satisfying, affordable, and full of personality. Many people meet Indonesian food through this everyday side of it first.",
459 + "tasteNotes": ["varied", "freshly cooked", "comforting", "bold"],
460 + "highlights": [
461 + "Street food can mean anything from a simple cart to a bustling market stall",
462 + "Texture, aroma, and immediacy matter as much as ingredients",
463 + "A good way to understand how broad Indonesian food really is"
464 + ],
465 + "region": "Across Indonesia",
466 + "aliases": ["street eats", "warung culture"],
467 + "tags": ["culture", "vendors", "markets", "everyday food"],
468 + "relatedIDs": ["nasi-goreng", "sate", "pempek", "martabak-manis"],
469 + "suggestedQuestions": [
470 + "What kinds of dishes are common as Indonesian street food?",
471 + "Why does street food matter so much here?",
472 + "What is a good first street food dish to try?"
473 + ],
474 + "imageName": null,
475 + "isFeatured": false
476 + },
477 + {
478 + "id": "festive-feasts",
479 + "kind": "tradition",
480 + "title": "Festive and special-occasion foods",
481 + "subtitle": "Food as gathering, generosity, and memory",
482 + "summary": "Many Indonesian dishes are tied closely to celebrations, family gatherings, religious moments, and ceremonial meals.",
483 + "story": "Indonesian food often makes the most sense when you see it as part of a gathering. Certain dishes feel everyday, while others carry a stronger sense of occasion, generosity, or communal pride. Even a small sweet snack can feel more meaningful when it appears in a festive spread beside savory dishes and shared plates.",
484 + "tasteNotes": ["varied", "abundant", "communal", "ceremonial"],
485 + "highlights": [
486 + "Special-occasion foods often emphasize abundance and sharing",
487 + "The same dish can feel ordinary in one setting and ceremonial in another",
488 + "Regional and family traditions matter a lot"
489 + ],
490 + "region": "Across Indonesia, with local differences",
491 + "aliases": ["celebration foods"],
492 + "tags": ["culture", "celebration", "family", "tradition"],
493 + "relatedIDs": ["klepon", "rendang", "gudeg", "martabak-manis"],
494 + "suggestedQuestions": [
495 + "Which dishes feel especially festive?",
496 + "Why does Indonesian food feel so communal?",
497 + "Where does klepon fit in a festive spread?"
498 + ],
499 + "imageName": null,
500 + "isFeatured": false
501 + }
502 +]
LICENSE-APACHE new
+202
@@ -0,0 +1,202 @@
1 +
2 + Apache License
3 + Version 2.0, January 2004
4 + http://www.apache.org/licenses/
5 +
6 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 +
8 + 1. Definitions.
9 +
10 + "License" shall mean the terms and conditions for use, reproduction,
11 + and distribution as defined by Sections 1 through 9 of this document.
12 +
13 + "Licensor" shall mean the copyright owner or entity authorized by
14 + the copyright owner that is granting the License.
15 +
16 + "Legal Entity" shall mean the union of the acting entity and all
17 + other entities that control, are controlled by, or are under common
18 + control with that entity. For the purposes of this definition,
19 + "control" means (i) the power, direct or indirect, to cause the
20 + direction or management of such entity, whether by contract or
21 + otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 + outstanding shares, or (iii) beneficial ownership of such entity.
23 +
24 + "You" (or "Your") shall mean an individual or Legal Entity
25 + exercising permissions granted by this License.
26 +
27 + "Source" form shall mean the preferred form for making modifications,
28 + including but not limited to software source code, documentation
29 + source, and configuration files.
30 +
31 + "Object" form shall mean any form resulting from mechanical
32 + transformation or translation of a Source form, including but
33 + not limited to compiled object code, generated documentation,
34 + and conversions to other media types.
35 +
36 + "Work" shall mean the work of authorship, whether in Source or
37 + Object form, made available under the License, as indicated by a
38 + copyright notice that is included in or attached to the work
39 + (an example is provided in the Appendix below).
40 +
41 + "Derivative Works" shall mean any work, whether in Source or Object
42 + form, that is based on (or derived from) the Work and for which the
43 + editorial revisions, annotations, elaborations, or other modifications
44 + represent, as a whole, an original work of authorship. For the purposes
45 + of this License, Derivative Works shall not include works that remain
46 + separable from, or merely link (or bind by name) to the interfaces of,
47 + the Work and Derivative Works thereof.
48 +
49 + "Contribution" shall mean any work of authorship, including
50 + the original version of the Work and any modifications or additions
51 + to that Work or Derivative Works thereof, that is intentionally
52 + submitted to Licensor for inclusion in the Work by the copyright owner
53 + or by an individual or Legal Entity authorized to submit on behalf of
54 + the copyright owner. For the purposes of this definition, "submitted"
55 + means any form of electronic, verbal, or written communication sent
56 + to the Licensor or its representatives, including but not limited to
57 + communication on electronic mailing lists, source code control systems,
58 + and issue tracking systems that are managed by, or on behalf of, the
59 + Licensor for the purpose of discussing and improving the Work, but
60 + excluding communication that is conspicuously marked or otherwise
61 + designated in writing by the copyright owner as "Not a Contribution."
62 +
63 + "Contributor" shall mean Licensor and any individual or Legal Entity
64 + on behalf of whom a Contribution has been received by Licensor and
65 + subsequently incorporated within the Work.
66 +
67 + 2. Grant of Copyright License. Subject to the terms and conditions of
68 + this License, each Contributor hereby grants to You a perpetual,
69 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 + copyright license to reproduce, prepare Derivative Works of,
71 + publicly display, publicly perform, sublicense, and distribute the
72 + Work and such Derivative Works in Source or Object form.
73 +
74 + 3. Grant of Patent License. Subject to the terms and conditions of
75 + this License, each Contributor hereby grants to You a perpetual,
76 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 + (except as stated in this section) patent license to make, have made,
78 + use, offer to sell, sell, import, and otherwise transfer the Work,
79 + where such license applies only to those patent claims licensable
80 + by such Contributor that are necessarily infringed by their
81 + Contribution(s) alone or by combination of their Contribution(s)
82 + with the Work to which such Contribution(s) was submitted. If You
83 + institute patent litigation against any entity (including a
84 + cross-claim or counterclaim in a lawsuit) alleging that the Work
85 + or a Contribution incorporated within the Work constitutes direct
86 + or contributory patent infringement, then any patent licenses
87 + granted to You under this License for that Work shall terminate
88 + as of the date such litigation is filed.
89 +
90 + 4. Redistribution. You may reproduce and distribute copies of the
91 + Work or Derivative Works thereof in any medium, with or without
92 + modifications, and in Source or Object form, provided that You
93 + meet the following conditions:
94 +
95 + (a) You must give any other recipients of the Work or
96 + Derivative Works a copy of this License; and
97 +
98 + (b) You must cause any modified files to carry prominent notices
99 + stating that You changed the files; and
100 +
101 + (c) You must retain, in the Source form of any Derivative Works
102 + that You distribute, all copyright, patent, trademark, and
103 + attribution notices from the Source form of the Work,
104 + excluding those notices that do not pertain to any part of
105 + the Derivative Works; and
106 +
107 + (d) If the Work includes a "NOTICE" text file as part of its
108 + distribution, then any Derivative Works that You distribute must
109 + include a readable copy of the attribution notices contained
110 + within such NOTICE file, excluding those notices that do not
111 + pertain to any part of the Derivative Works, in at least one
112 + of the following places: within a NOTICE text file distributed
113 + as part of the Derivative Works; within the Source form or
114 + documentation, if provided along with the Derivative Works; or,
115 + within a display generated by the Derivative Works, if and
116 + wherever such third-party notices normally appear. The contents
117 + of the NOTICE file are for informational purposes only and
118 + do not modify the License. You may add Your own attribution
119 + notices within Derivative Works that You distribute, alongside
120 + or as an addendum to the NOTICE text from the Work, provided
121 + that such additional attribution notices cannot be construed
122 + as modifying the License.
123 +
124 + You may add Your own copyright statement to Your modifications and
125 + may provide additional or different license terms and conditions
126 + for use, reproduction, or distribution of Your modifications, or
127 + for any such Derivative Works as a whole, provided Your use,
128 + reproduction, and distribution of the Work otherwise complies with
129 + the conditions stated in this License.
130 +
131 + 5. Submission of Contributions. Unless You explicitly state otherwise,
132 + any Contribution intentionally submitted for inclusion in the Work
133 + by You to the Licensor shall be under the terms and conditions of
134 + this License, without any additional terms or conditions.
135 + Notwithstanding the above, nothing herein shall supersede or modify
136 + the terms of any separate license agreement you may have executed
137 + with Licensor regarding such Contributions.
138 +
139 + 6. Trademarks. This License does not grant permission to use the trade
140 + names, trademarks, service marks, or product names of the Licensor,
141 + except as required for reasonable and customary use in describing the
142 + origin of the Work and reproducing the content of the NOTICE file.
143 +
144 + 7. Disclaimer of Warranty. Unless required by applicable law or
145 + agreed to in writing, Licensor provides the Work (and each
146 + Contributor provides its Contributions) on an "AS IS" BASIS,
147 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 + implied, including, without limitation, any warranties or conditions
149 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 + PARTICULAR PURPOSE. You are solely responsible for determining the
151 + appropriateness of using or redistributing the Work and assume any
152 + risks associated with Your exercise of permissions under this License.
153 +
154 + 8. Limitation of Liability. In no event and under no legal theory,
155 + whether in tort (including negligence), contract, or otherwise,
156 + unless required by applicable law (such as deliberate and grossly
157 + negligent acts) or agreed to in writing, shall any Contributor be
158 + liable to You for damages, including any direct, indirect, special,
159 + incidental, or consequential damages of any character arising as a
160 + result of this License or out of the use or inability to use the
161 + Work (including but not limited to damages for loss of goodwill,
162 + work stoppage, computer failure or malfunction, or any and all
163 + other commercial damages or losses), even if such Contributor
164 + has been advised of the possibility of such damages.
165 +
166 + 9. Accepting Warranty or Additional Liability. While redistributing
167 + the Work or Derivative Works thereof, You may choose to offer,
168 + and charge a fee for, acceptance of support, warranty, indemnity,
169 + or other liability obligations and/or rights consistent with this
170 + License. However, in accepting such obligations, You may act only
171 + on Your own behalf and on Your sole responsibility, not on behalf
172 + of any other Contributor, and only if You agree to indemnify,
173 + defend, and hold each Contributor harmless for any liability
174 + incurred by, or claims asserted against, such Contributor by reason
175 + of your accepting any such warranty or additional liability.
176 +
177 + END OF TERMS AND CONDITIONS
178 +
179 + APPENDIX: How to apply the Apache License to your work.
180 +
181 + To apply the Apache License to your work, attach the following
182 + boilerplate notice, with the fields enclosed by brackets "[]"
183 + replaced with your own identifying information. (Don't include
184 + the brackets!) The text should be enclosed in the appropriate
185 + comment syntax for the file format. We also recommend that a
186 + file or class name and description of purpose be included on the
187 + same "printed page" as the copyright notice for easier
188 + identification within third-party archives.
189 +
190 + Copyright 2026 Onde Inference (Splitfire AB)
191 +
192 + Licensed under the Apache License, Version 2.0 (the "License");
193 + you may not use this file except in compliance with the License.
194 + You may obtain a copy of the License at
195 +
196 + http://www.apache.org/licenses/LICENSE-2.0
197 +
198 + Unless required by applicable law or agreed to in writing, software
199 + distributed under the License is distributed on an "AS IS" BASIS,
200 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 + See the License for the specific language governing permissions and
202 + limitations under the License.
LICENSE-MIT new
+21
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Onde Inference (Splitfire AB)
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
README.md new
+154
@@ -0,0 +1,154 @@
1 +# Klepon: Taste of Indonesia
2 +
3 +Klepon is an iPhone-first SwiftUI app for exploring Indonesian dishes, ingredients, and food traditions. The app is built to feel warm, simple, and curated rather than like a generic chatbot or a giant food database.
4 +
5 +## Why this project exists
6 +
7 +Indonesian food deserves better than scattered search results and shallow summaries.
8 +
9 +Klepon is a small, focused app project built to help people:
10 +
11 +- discover Indonesian dishes with context
12 +- understand ingredients and food traditions
13 +- browse locally bundled content with a calmer reading experience
14 +- ask follow-up questions privately on-device
15 +
16 +## Project status
17 +
18 +This repo is under active development, but the V1 direction is already in place:
19 +
20 +- curated Indonesian food content bundled locally
21 +- private on-device follow-up answers powered by [Onde Inference](https://ondeinference.com).
22 +- iPhone-first SwiftUI UI and interaction model
23 +- local search, saved items, recent searches, and recently viewed continuity
24 +
25 +## Repository layout
26 +
27 +- `Klepon/` — the iOS app source
28 +- `Klepon.xcodeproj/` — the Xcode project
29 +- `Scripts/validate_content.py` — validates bundled content integrity
30 +- `SECURITY.md` — security handling and disclosure guidance
31 +
32 +## Tech stack
33 +
34 +- SwiftUI
35 +- local JSON content
36 +- small Python utility for content validation
37 +
38 +## Getting started
39 +
40 +### Requirements
41 +
42 +- macOS
43 +- Xcode 26+
44 +- Python 3 if you want to run the content validation script
45 +- an Apple Developer signing setup if you want to run on a physical iPhone
46 +
47 +### Open the project
48 +
49 +Open:
50 +
51 +- `Klepon.xcodeproj`
52 +
53 +### Build on simulator
54 +
55 +```/dev/null/sh#L1-1
56 +xcodebuild -project Klepon.xcodeproj -scheme Klepon -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
57 +```
58 +
59 +### Validate bundled content
60 +
61 +```/dev/null/sh#L1-1
62 +python3 Scripts/validate_content.py
63 +```
64 +
65 +## Open-source signing notes
66 +
67 +This repository is published as source code, so you should expect to adapt signing-related settings for your own Apple Developer account.
68 +
69 +### Bundle identifier
70 +
71 +The project currently uses:
72 +
73 +- `ai.splitfire.klepon`
74 +
75 +If you fork this project for your own team, change that bundle identifier to one you control.
76 +
77 +### Entitlements and App Group
78 +
79 +The project includes an App Group entitlement and an increased memory entitlement so the app can manage on-device model files more reliably on iPhone.
80 +
81 +If you run your own fork under your own Apple Developer account, you will usually want to replace the App Group identifier with one you control.
82 +
83 +## Security and secrets
84 +
85 +I checked the repository for common secret-leak patterns before updating this README.
86 +
87 +That included scans for things like:
88 +
89 +- API keys
90 +- private keys
91 +- password-like values
92 +- provisioning material
93 +- hardcoded team identifiers in the active project
94 +
95 +No real secrets were found in the tracked project files.
96 +
97 +### What should never be committed
98 +
99 +- API keys
100 +- private keys or certificates
101 +- provisioning profiles
102 +- local-only xcconfig files with secrets
103 +- `.env` files with credentials
104 +
105 +### Practical security rules for this repo
106 +
107 +1. Keep local credentials local.
108 +2. Never hardcode tokens or keys in Swift, JSON, or Xcode project settings.
109 +3. Treat bundle IDs and App Groups as identifiers, not secrets.
110 +4. Replace shared signing identifiers with your own if you are running a fork.
111 +5. Re-check diffs before pushing anything that touches auth, signing, or deployment.
112 +
113 +The `.gitignore` already excludes the main categories of files that should stay off Git:
114 +
115 +- local env files
116 +- local xcconfig overrides
117 +- signing keys and certificates
118 +- provisioning profiles
119 +- editor and user-state files
120 +- Python cache files
121 +
122 +For more detail, see `SECURITY.md`.
123 +
124 +## Contributing
125 +
126 +Contributions are welcome, especially around:
127 +
128 +- content quality and cultural accuracy
129 +- SwiftUI product polish
130 +- accessibility
131 +- app performance and memory behavior
132 +
133 +Before opening a PR:
134 +
135 +1. build the app
136 +2. run `python3 Scripts/validate_content.py` if you changed bundled content
137 +3. make sure you did not add secrets, signing material, or local machine artifacts
138 +
139 +## Responsible disclosure
140 +
141 +If you find a security issue, please avoid posting exploit details in a public issue before maintainers have had a chance to triage it.
142 +
143 +## License
144 +
145 +Klepon is dual-licensed under **MIT** and **Apache 2.0**. You can use either one.
146 +
147 +- [MIT License](LICENSE-MIT)
148 +- [Apache License 2.0](LICENSE-APACHE)
149 +
150 +---
151 +
152 +## Copyright
153 +
154 +© 2026 [Onde Inference](https://ondeinference.com) (Splitfire AB).
SECURITY.md new
+39
@@ -0,0 +1,39 @@
1 +# Security Policy
2 +
3 +## Supported scope
4 +
5 +This repository is an open-source iOS application project.
6 +
7 +Security-sensitive areas include:
8 +
9 +- on-device model cache handling
10 +- app sandbox / App Group configuration
11 +- entitlements and signing setup
12 +- accidental token or credential commits
13 +- third-party dependency changes
14 +
15 +## Reporting a vulnerability
16 +
17 +If you believe you found a security issue:
18 +
19 +1. avoid posting exploit details in a public issue immediately
20 +2. provide a minimal reproduction and affected files
21 +3. give maintainers reasonable time to triage before wider disclosure
22 +
23 +## What not to commit
24 +
25 +Never commit:
26 +
27 +- Hugging Face tokens
28 +- API keys
29 +- App Store Connect keys
30 +- private keys or certificates
31 +- provisioning profiles
32 +- local-only xcconfig files with secrets
33 +- `.env` files with credentials
34 +
35 +## Local development guidance
36 +
37 +- use local-only config for secrets
38 +- keep signing identities and provisioning material outside source control
39 +- replace shared App Group or bundle identifiers with values you control if you are running your own fork
Scripts/validate_content.py new
+60
@@ -0,0 +1,60 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +from pathlib import Path
5 +import sys
6 +
7 +ROOT = Path(__file__).resolve().parents[1]
8 +CONTENT_DIR = ROOT / "Klepon" / "Resources" / "Content"
9 +ENTRIES_PATH = CONTENT_DIR / "guide_entries.json"
10 +COLLECTIONS_PATH = CONTENT_DIR / "collections.json"
11 +
12 +
13 +def fail(message: str) -> None:
14 + print(f"ERROR: {message}")
15 + sys.exit(1)
16 +
17 +
18 +def main() -> None:
19 + entries = json.loads(ENTRIES_PATH.read_text())
20 + collections = json.loads(COLLECTIONS_PATH.read_text())
21 +
22 + if not entries:
23 + fail("guide_entries.json is empty")
24 +
25 + ids = [entry["id"] for entry in entries]
26 + unique_ids = set(ids)
27 +
28 + if len(ids) != len(unique_ids):
29 + duplicates = sorted({entry_id for entry_id in ids if ids.count(entry_id) > 1})
30 + fail(f"duplicate entry ids: {', '.join(duplicates)}")
31 +
32 + missing_featured = not any(entry.get("isFeatured") for entry in entries)
33 + if missing_featured:
34 + fail("at least one entry must be marked isFeatured=true")
35 +
36 + for entry in entries:
37 + for field in ["id", "title", "subtitle", "summary", "story"]:
38 + value = entry.get(field)
39 + if not isinstance(value, str) or not value.strip():
40 + fail(f"entry {entry.get('id', '<unknown>')} has invalid field: {field}")
41 +
42 + for related_id in entry.get("relatedIDs", []):
43 + if related_id not in unique_ids:
44 + fail(f"entry {entry['id']} references missing related id: {related_id}")
45 +
46 + for collection in collections:
47 + if not collection.get("entryIDs"):
48 + fail(f"collection {collection.get('id', '<unknown>')} has no entryIDs")
49 +
50 + for entry_id in collection["entryIDs"]:
51 + if entry_id not in unique_ids:
52 + fail(f"collection {collection['id']} references missing entry id: {entry_id}")
53 +
54 + print(
55 + f"Content validation passed: {len(entries)} entries, {len(collections)} collections, {sum(1 for entry in entries if entry.get('isFeatured'))} featured."
56 + )
57 +
58 +
59 +if __name__ == "__main__":
60 + main()