Let's look at how to architect and build a production-grade, multi-screen Flutter application that integrates generative AI models and dynamic user interfaces at runtime.
What you'll learn
- Structuring a Flutter and Firebase monorepo for atomic deployments
- Implementing declarative routing guards with
GoRouterand Firebase custom claims - Orchestrating multi-step AI image generation and moderation workflows
- Rendering dynamic UI components (
GenUI) based on runtime model payloads
Monorepo Architecture and Project Layout
When building an app with multiple distinct client surfaces—like a kiosk, a kitchen display, and a public queue screen—separation of concerns starts at the repository level. We use a monorepo setup to share core business logic and types between our Flutter frontend and Firebase backend functions.
// firebase.json
{
"hosting": {
"public": "build/web",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
},
"firestore": {
"rules": "firestore.rules"
}
}
Speed Note: Keeping your shared TypeScript types or Dart models in a dedicated package eliminates drift between what your Cloud Functions expect and what your UI payloads submit.
Securing Multi-Screen Web Apps with Declarative Routing
Deploying a Flutter app to the web means users can manipulate the browser address bar directly. If you have administrative or back-of-house screens (like barista dashboards or content moderators), you must enforce strict routing guards at the router level.
// router.dart
import 'package:go_router/go_router.dart';
import 'package:firebase_auth/firebase_auth.dart';
final GoRouter appRouter = GoRouter(
initialLocation: '/',
redirect: (context, state) async {
final user = FirebaseAuth.instance.currentUser;
final loggedIn = user != null;
final isModeratorRoute = state.matchedLocation.startsWith('/moderator');
if (isModeratorRoute) {
if (!loggedIn) return '/login';
// Check Firebase custom claims for elevated permissions
final tokenResult = await user.getIdTokenResult();
final claims = tokenResult.claims ?? {};
final isMod = claims['moderator'] == true;
if (!isMod) return '/unauthorized';
}
return null;
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const KioskHomeScreen(),
),
GoRoute(
path: '/moderator',
builder: (context, state) => const ModeratorDashboardScreen(),
),
],
);
Gotcha: Always verify custom claims on the server side inside your Firestore security rules and Cloud Functions. Client-side routing redirects are for UX only; they do not replace backend security.
Locking Down Firestore Security Rules
Never rely solely on client-side validation when accepting user inputs or handling write operations. Tie every write to authenticated user credentials with strict field-level validation rules in Firestore.
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /orders/{orderId} {
// Allow public creation of orders with strict schema validation
allow create: if request.auth != null
&& request.resource.data.keys().hasAll(['prompt', 'status', 'createdAt'])
&& request.resource.data.prompt is string
&& request.resource.data.prompt.size() <= 50
&& request.resource.data.status == 'pending';
// Only allow moderators or assigned baristas to update order status
allow update: if request.auth != null
&& (request.auth.token.moderator == true || request.auth.token.barista == true);
allow read: if true;
}
}
}
Orchestrating Multi-Tier Content Moderation
When accepting unstructured user text (like a "happy place" prompt), you need a multi-layered safety check before passing data to expensive generative image pipelines.
// moderation.ts
import { onRequest } from 'firebase-functions/v2/https';
import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI();
export const moderatePrompt = onRequest(async (req, res) => {
const { prompt } = req.body;
if (!prompt || typeof prompt !== 'string' || prompt.length > 50) {
res.status(400).send({ error: 'Invalid prompt length or type.' });
return;
}
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: [
{
role: 'user',
parts: [
{ text: `Review the following user prompt for NSFW or inappropriate content. Output strictly JSON with keys "safe" (boolean) and "reason" (string).\n\nPrompt: "${prompt}"` }
]
}
]
});
const resultText = response.text ?? '{}';
const moderationResult = JSON.parse(resultText);
res.status(200).json(moderationResult);
} catch (err) {
res.status(500).send({ error: 'Moderation service failed.' });
}
});
Accessibility & Safety Tip: Combine automated LLM checks with internal image generation model filters (like safety classifiers in image APIs) and a final human-in-the-loop verification screen for public-facing deployments.
Rendering Dynamic UIs from Model Payloads
To build truly dynamic experiences, let your backend model return structural UI configuration payloads—often called GenUI. Depending on the question type returned by the model, your Flutter app can dynamically select the right widget from a restricted design-system catalog.
// dynamic_renderer.dart
import 'package:flutter/material.dart';
class DynamicQuestionRenderer extends StatelessWidget {
final Map<String, dynamic> schema;
final ValueChanged<String> onAnswerChanged;
const DynamicQuestionRenderer({
super.key,
required this.schema,
required this.onAnswerChanged,
});
@override
Widget build(BuildContext context) {
final String type = schema['type'] ?? 'text';
final String label = schema['label'] ?? '';
switch (type) {
case 'text':
return TextField(
decoration: InputDecoration(labelText: label),
onChanged: onAnswerChanged,
);
case 'choices':
final List<String> options = List<String>.from(schema['options'] ?? []);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.titleMedium),
...options.map((option) => RadioListTile<String>(
title: Text(option),
value: option,
groupValue: null,
onChanged: (val) => val != null ? onAnswerChanged(val) : null,
)),
],
);
default:
return const Text('Unsupported UI component requested.');
}
}
}
Speed Note: Keep your runtime widget catalog small and constrained to predefined design system tokens. Giving LLMs total freedom over raw widget trees usually leads to layout breaks and brand inconsistency.
Summary and Next Steps
By combining Flutter's flexible multi-platform UI engine with Firebase's reactive backend services and generative AI models, you can ship complex real-time applications that adapt dynamically at runtime.
- Next Steps:
- Set up a monorepo structure separating your Flutter client code from Firebase functions and shared types.
- Implement strict server-side validation rules and declarative routing guards to protect administrative dashboards.
- Explore constrained
GenUIpatterns to let AI models render appropriate input widgets dynamically.