iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐷

Building a Low-Cost AI Fitness App Using Gemini 2.5 Flash-Lite, Go (Gin), and GCP

に公開

1. Introduction

In individual development, the biggest enemies are "running costs" and "maintaining motivation."
This time, I released a fitness app called BuddyLift, which "wholeheartedly affirms the user's efforts."

The most significant feature of this app is that even if it's just a 10-second workout, the AI (named Bal-kun) will give you overwhelming praise.
In this article, I will share the low-cost and robust AI implementation using Gemini 2.5 Flash-Lite, along with a serverless architecture that minimizes operational costs to the extreme.

2. System Architecture: Pursuing $0/month by Combining Managed Services

Here is the architecture I built with the obsession that "I don't want to pay a single cent for time when the app isn't being used."

User Management Implemented with Firestore

I adopted Cloud Firestore for storing user data. By optimizing the number of read and write operations, the configuration is designed to easily stay within the free tier at an individual development scale.

Go (Gin) × Cloud Run

Leveraging Go's lightweight binaries, I minimized Cloud Run's cold starts. During times without requests, the instances scale to zero, completely stopping any charges.

3. "Type-Safe" Prompt Strategy with Gemini 2.5 Flash-Lite

When handling AI responses in an app, the scariest thing is inconsistency in the output format. By utilizing ResponseSchema from the Google AI SDK for Go, I enforced types on the API side.

Enforcing Responses through Schema Definition

Instead of relying solely on the prompt, I defined the structure at the code level.

model.ResponseMIMEType = "application/json"
model.ResponseSchema = &genai.Schema{
    Type: genai.TypeObject,
    Properties: map[string]*genai.Schema{
        "suggested_action": {
            Type:        genai.TypeString,
            Description: "Only the specific name of the training exercise (e.g., Squat, Plank).",
        },
        "advice": {
            Type:        genai.TypeString,
            Description: "Explain why this exercise was chosen based on the history, within 30 characters.",
        },
        "method": {
            Type:        genai.TypeString,
            Description: "Explain the correct way to perform the exercise in about 3 steps.",
        },
    },
    Required: []string{"suggested_action", "advice", "method"},
}

Even for 10 seconds of exercise data, "Bal-kun" returns optimal advice and explosive praise following this schema.

4. Android Implementation: Capturing with a "Stealth Layer" of Infinite Height

The UI is built with Jetpack Compose. For SNS sharing, I established a mechanism to capture long screens that extend beyond the scroll in one go.

Challenge: Capturing the display UI as-is leads to clipping

With standard capturing, you can only record the area currently visible on the screen. On the other hand, it is inefficient to rebuild the UI just for the sake of capturing it.

Solution: Virtual Rendering with GraphicsLayer and layout

I adopted a method of overlaying a dedicated layer that is "invisible to the user but internally rendered at full size."

val graphicsLayer = rememberGraphicsLayer()

Box(modifier = Modifier.fillMaxSize()) {
    // --- 1. Capture-only layer (Never visible to the user) ---
    Box(
        modifier = Modifier
            .layout { measurable, constraints ->
                // ★ Point: Measure with "infinite" height constraints
                val infiniteHeightConstraints = constraints.copy(
                    minHeight = 0,
                    maxHeight = Constraints.Infinity
                )
                val placeable = measurable.measure(infiniteHeightConstraints)

                // Report 0,0 to the parent element (Box) so it doesn't take up space on the screen
                layout(0, 0) {
                    placeable.place(0, 0)
                }
            }
            .drawWithCache {
                onDrawWithContent {
                    // Record content to graphicsLayer
                    graphicsLayer.record {
                        this@onDrawWithContent.drawContent()
                    }
                    // Do not draw to the actual screen (Canvas)
                }
            }
    ) {
        Column(modifier = Modifier.width(360.dp).background(backgroundBrush)) {
            // Full content without scrolling
            SuccessContent(streakCount, message)
        }
    }

    // --- 2. Display layer (Scrollable screen for user interaction) ---
    Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
        SuccessContent(streakCount, message)
    }
}

Through this implementation—which is almost a hack, where "measurement is infinite but reporting is zero"—it became possible to instantly generate an "ultra-long image with the exact same design as the screen currently being viewed."

5. Conclusion

As the culmination of the architecture introduced here (Go/Gin, Gemini 2.5, Firestore, Compose), I have released the fitness app BuddyLift.

This app is the result of trial and error to maximize user experience while keeping costs low. I would be happy if this article provides some hints for your own individual development, and as a developer, nothing would bring me more joy than having you actually try the app and provide feedback.

BuddyLift 〜 Training with Bal-kun 〜
https://play.google.com/store/apps/details?id=com.sho1009.buddylift&referrer=utm_source%3Dzenn%26utm_medium%3Dreferral%26utm_campaign%3Ddev_article

Discussion