I am coming towards the end of my 3rd week working on Shipaton, and with that comes another chance to pause and update you on my progress. Note: This is the 3rd post about my work towards shipping an app for the Shipaton 2026 hackathon. If you want to read from the beginning you can find the link to the first post here As a recap I’m building a multi-layered application called Buildhorn so users can receive CI build failures on their phone. I’m using the following technologies: Kotlin Multiplatform (Shared Kotlin Library, handles networking, file storage and business logic) iOS App (iOS app built using SwiftUI, focuses on the UI layer and consumes the Kotlin Multiplatform library for its business logic) Firebase Backend (An all-in-one backend using Firebase Cloud Functions for endpoint creation / webhook event handling, Firebase Cloud Messaging for push messaging and Firestore for backend persistence) The past week has been focused on polish so it comes across as a high quality app. I have the end to end flow complete, where a failed GitHub workflow run results in a failed run making it to my Firestore storage and a notification being sent via Firebase Cloud Messaging. This was a big moment for the app as it helped verify the niche the app is aiming to fill. Next I wanted to inject some fun into the app so it has a bit of a personality. With that in mind, here’s what’s new this week: A new Firestore-backed endpoint so the app always has an accurate list of watched repositories Custom notification sounds, picked during onboarding A confetti animation to celebrate connecting your GitHub account Notification copy with personality, adding emoji and a rotating cast of build-failure jokes Let’s dive deeper and look at how each part of the application is contributing to this. Kotlin Multiplatform This week there is relatively little change to the dependencies used by the Kotlin shared library. All the dependencies added in week two are continuing to work well. As part of polishing the experience I did find myself needing to map the response from a new cloud functions endpoint to provide the list of repositories a user has enabled Buildhorn to watch. Without it the app had a caching issue where a previous update was not being shown quickly enough. This new endpoint means I can always refer to Firestore as the source of truth if needed. I am also finding gradual optimisations where business logic existing within the iOS app might be better suited in the shared library. Usually this process happens in the following steps: I look through the code for a screen as I test I realise that the code (or portions of it) would be better placed within the KMP layer, if I wanted the same logic to exist across platforms one day I ask Claude (or Junie!) to move the logic into the KMP layer. I also ask it to adhere to the shared library architecture and rewire the screen to use the supporting ViewModel I find this process works quite well for my own needs and requires relatively little reprompting. I suspect this is helped by having a strong CLAUDE.md / AGENTS.md in place to help guide the architecture. iOS App One of the most notable changes since last week is the ability for the user to customise their notification sound. The core experience around Buildhorn is being able to know when your CI build is failing so why not have some fun with it? To do that I added a new screen as part of the onboarding flow, to allow users to pick their desired notification sound. Now when a notification is received the app can then use the sound to schedule the notification using their selected sound. I intentionally left a system default setting if a user doesn’t want to change their notification sound. Unfortunately iOS doesn’t allow you to access the default notification noise from the system, but it seems helpful to have an option. Maybe some people who will use the app work in very professional settings, and the sound of an airhorn going off might not be appropriate. 😅 The notification is scheduled when the payload is received on a failed CI build: func scheduleLocalNotification(title: String, body: String) { guard !title.isEmpty || !body.isEmpty else { return } let content = UNMutableNotificationContent() content.title = title content.body = body // Get the selected sound let soundName = UserDefaults.standard.string(forKey: "alertSound") ?? AlertSound.deviceDefault.rawValue // If the device default is selected, use the default sound if soundName == AlertSound.deviceDefault.rawValue { content.sound = .default } else { content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "\(soundName).mp3")) } // Add the notification UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: "local-\(UUID().uuidString)", content: content, trigger: nil) ) } The notification must be scheduled locally, otherwise the notification payload sent from the server retains control of the notification. Users can revisit this screen anytime to update their sound from the settings screen. One last improvement worth mentioning is the addition of a confetti animation on the connected screen. This is a small way to reward the user once they’ve connected their account to GitHub: I often find myself enjoying apps that add small things like this, so I’m hoping others will appreciate the effort to make the onboarding as engaging as possible.Firebase Backend A few small tweaks were made to the backend this week. These were: A new cloud function to fetch the repositories being watched by Buildhorn Updating the notification payload so the title and text have some personality Let’s focus on the notification payload. This is handled by a push cloud function, which is responsible for sending push notifications to devices via Firebase Cloud Messaging. Here’s the payload data the function sends when scheduling a push notification: data: { title: "Build failed!", body: `${data.repoFullName} failed to run the ${data.workflowName} ` + `workflow.`, repoFullName: data.repoFullName, runUrl: data.runUrl, }, android: { priority: "high", }, apns: { headers: { "apns-push-type": "background", "apns-priority": "5", }, payload: { aps: {contentAvailable: true}, }, }, As you can see, the title and body fields provide the notification title and body. Resulting in the following notification: Basic Notification Messaging To help give the notifications some personality I added a collection of jokes and a function to randomly pick a joke: const BUILD_FAILURE_JOKES = [ "Even your code needs a coffee break. ☕️", "Somewhere, a semicolon is laughing at you. 😭", "It's not a bug, it's an undocumented feature that broke the build. 📚", "Your pipeline just rage-quit. 😡", "404: successful build not found. 🔎", "On the bright side, at least it failed fast.", "The build gods have spoken, and they said no. 🙈", "Ctrl+Z isn't going to save this one. 😅", "Maybe use auto mode less on Claude next time. 😏", ]; /** * Pick a random lighthearted joke to soften the blow of a build failure. * * @return {string} A randomly selected build-failure joke. */ function getRandomFailureJoke(): string { return BUILD_FAILURE_JOKES[ Math.floor(Math.random() * BUILD_FAILURE_JOKES.length) ]; } Then I updated the title and body to add some emojis and append the random joke to each notification: data: { title: "Build failed! 🚨‼️💥", body: `${data.repoFullName} failed to run the ${data.workflowName} ` + `workflow. ${getRandomFailureJoke()}`, repoFullName: data.repoFullName, runUrl: data.runUrl, }, android: { priority: "high", }, apns: { headers: { "apns-push-type": "background", "apns-priority": "5", }, payload: { aps: {contentAvailable: true}, }, } Now, when a notification message is sent the message looks like this: Improved Notification Messaging With a few small changes to the copy, Buildhorn now has a fun personality beyond your average CI status checker. I am continuing to vibe code a fair bit for this project. I continue to have my harness of CLAUDE.md and AGENTS.md set up, with no custom skills in use except the Firebase Agent Skills. As I begin to polish the app I often find myself using the following process: Testing the app and finding an issue. Taking a screenshot, uploading it to my computer, then asking Claude / Junie to fix the behaviour, using the screenshot as evidence When the change is made retest and verify it works as expected. A couple of recent examples where I used this are: Text clipping awkwardly inside a notification card A screen’s UI state not catching up after a repository was connected. It’s interesting as I find myself swapping to the role of a QA tester during this phase and reprompting / refining the output as it is generated. Sometimes the output just isn’t quite what I want, or it does something unexpected. Usually I repeat the process until it works. On the odd occasion I find it is taking more than I would like, and just revert to coding the solution. For some problems this is an easy thing to do, for other things it can be time consuming but necessary if I want complete control over the solution. Next Steps After three weeks, I am starting to see v1.0 is very close to release. My focus for the next week is: Adding / Polishing RevenueCat support for subscriptions Continue polishing the app Continue thinking about the marketing strategy That’s all for this post. If you would like to help test the app, you can join the TestFlight external testers group. Thank you for reading and keep an eye out for the next post on my progress building for Shipaton 2026!
Building Buildhorn: Shipaton 2026 Week Three
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.