Build a Storytelling App with Flutter and Gemini AI

In my previous article, we looked at the Google AI Dart SDK and how we can use it in Flutter/Dart applications. I promised to provide a sample application in the upcoming article, so here I am today. We will develop a storytelling application in Flutter using the Google Generative AI package. So, without further ado, let's start.

An AI Storytelling App

Let's make a small storyteller app using Flutter and Gemini AI. In this app, we will provide the prompt and image to Gemini AI, which will respond with a story based on the image, as shown in the app flow diagram below.

Flow diagram

Now, join me in completing these steps to build it.

Step 1. Installation

Follow the steps below to enable Google Gemini in your Flutter project.

Get an API key: You can head over to  https://aistudio.google.com/app/apikey and create a Gemini API key.

Add the dependency: You need to add the google_generative_ai dependency to the pubspec.yaml file in your flutter project.

dependencies: 
  google_generative_ai: ^0.2.0

Add the assets: You must include the image assets folder in your pubspec.yaml file.

assets:
    - images/

Run the Flutter pub and get after that.

Step 2. Folder Structure

Create a folder structure similar to the one shown below, and put all of your images in the image folder.

Folder structure

Step 3. Add Your API Key

Add the Gemini API key to the utils/constants.dart file.

const String apiKey = 'YOUR_API_KEY';

Replace the YOUR_API_KEY with your actual Gemini API key.

Step 4. Add Images Resources

In the utils/images.dart file, I've centralized all of the asset image paths.

class ImageResources {
  static const String elephantAndTailor = 'images/elephant-and-the-tailor.jpg';
  static const String freeBird = 'images/free_bird.jpeg';
  static const String lionAndMouse = 'images/lion_and_mouse.jpeg';
  static const String lionAndRabbit = 'images/lion_and_rabbit.jpeg';
  static const String lostAndFound = 'images/lost_and_found.jpeg';
  static const String wolfWolf = 'images/wolf_wolf.jpeg';
  static const String sweetGrapes = 'images/sweet_grapes.jpeg';
  static const String literateAndIliterate = 'images/Literate_and-_lliterate.jpeg';
}

Step 5. Create a Home Page (pages/home_page.dart).

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:storyteller/utils/images.dart';
import 'package:storyteller/pages/story_page.dart';

// HomePage is a StatelessWidget that displays a grid of images.
// Each image can be clicked to navigate to a StoryPage.
class HomePage extends StatelessWidget {
  // List of image resources to be displayed in the grid.
  List<String> images = [
    ImageResources.sweetGrapes,
    ImageResources.lionAndMouse,
    ImageResources.freeBird,
    ImageResources.lostAndFound,
    ImageResources.elephantAndTailor,
    ImageResources.wolfWolf,
    ImageResources.lionAndRabbit,
    ImageResources.literateAndIliterate
  ];

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Storyteller'), // AppBar with the title 'Storyteller'
      ),
      body: GridView.builder(
        padding: const EdgeInsets.all(8.0), // Padding around the grid
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2, // number of items per row
          crossAxisSpacing: 8, // spacing between items horizontally
          mainAxisSpacing: 8, // spacing between items vertically
        ),
        itemCount: images.length, // The number of items in the grid is the number of images
        itemBuilder: (context, index) {
          // For each item, create an InkWell widget that navigates to a StoryPage when tapped.
          return InkWell(
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                    builder: (context) => StoryPage(imagePath: images[index])), // Pass the image path to the StoryPage
              );
            },
            child: Container(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: AssetImage(images[index]), // Display the image
                  fit: BoxFit.cover, // Make the image cover the entire grid item
                ),
                borderRadius: BorderRadius.circular(10), // Round the corners of the grid items
              ),
            ),
          );
        },
      ),
    );
  }
}

Home Page displays a grid of images using the GridView.builder widget. Each grid item is an InkWell widget that, when tapped, navigates to a StoryPage with the corresponding image.

Now, call the HomePage widget to main.dart.

import 'package:flutter/material.dart';
import 'package:storyteller/pages/home_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: HomePage(),
    );
  }
}

Step 6. Create a Story Page (pages/story_page.dart).

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:storyteller/utils/constants.dart';
import 'dart:typed_data';

// StoryPage is a StatefulWidget that displays a story generated from an image.
class StoryPage extends StatefulWidget {
  final String imagePath; // Path to the image that the story is based on

  const StoryPage({super.key, required this.imagePath});

  @override
  State<StoryPage> createState() => _StoryPageState();
}

class _StoryPageState extends State<StoryPage> {
  List<String> story = []; // List of strings that make up the story
  bool isLoading = true; // Whether the story is currently being loaded

  // Load an image from the assets and return its bytes
  Future<Uint8List> loadImageAssetBytes(String path) async {
    ByteData data = await rootBundle.load(path);
    return data.buffer.asUint8List();
  }

  // Compose a story based on the image at widget.imagePath
  void composeStory() async {
    story.clear();
    final model = GenerativeModel(model: 'gemini-pro-vision', apiKey: apiKey);
    final prompt = 'Generate a story behind this image?';
    final lionBytes = await loadImageAssetBytes(widget.imagePath);

    final content = [
      Content.multi([TextPart(prompt), DataPart('image/jpeg', lionBytes)])
    ];
    final responses = model.generateContentStream(content);
    await for (final response in responses) {
      story.add(response.text!.trim());
    }
    setState(() {
      isLoading = false; // The story has been loaded
    });
  }

  @override
  void initState() {
    super.initState();
    composeStory(); // Compose the story when the widget is initialized
  }

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (context, innerBoxIsScrolled) {
          return [
            SliverAppBar(
              expandedHeight: size.height * 0.4,
              floating: false,
              pinned: true,
              iconTheme: IconThemeData(
                  color: innerBoxIsScrolled ? Colors.black : Colors.white),
              flexibleSpace: FlexibleSpaceBar(
                background: Image.asset(
                  widget.imagePath,
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ];
        },
        body: isLoading
            ? const Center(child: CircularProgressIndicator()) // Show a loading spinner while the story is being loaded
            : ListView.builder(
          padding: const EdgeInsets.all(8.0),
          shrinkWrap: true,
          physics: const ClampingScrollPhysics(),
          itemCount: story.length, // The number of items in the list is the number of strings in the story
          itemBuilder: (context, index) {
            return Text(
              story[index], // Display each string in the story as a separate item in the list
              style: Theme.of(context).textTheme.bodyLarge,
            );
          },
        ),
      ),
    );
  }
}

The story page takes an image path as a parameter. The page uses Google's Generative AI to generate a story based on the provided image.

  1. loadImageAssetBytes function loads the image from the provided path as a byte array.
  2. composeStory function clears the existing story, initializes the generative model with the API key, and sets a prompt. It then loads the image bytes and generates a content stream. The generated story is added to the story list. Once the story is generated, it sets isLoading to false to indicate that it is ready to display.
  3. initState function calls composeStory to generate the story when the widget is initialized.

Storyteller app

Story

Conclusion

In this post, we explored how to use the Gemini API with the help of the Google generative AI package to build a storyteller application in Flutter that generates a story based on an image. You can construct various types of applications utilizing Gemini with Flutter. For example,

  • Summarise lengthy texts and capture their key points.
  • Build smart chatbots that resemble human conversations
  • A visual search engine that allows users to upload pictures and receive descriptions
  • And many more

If you found this article helpful, feel free to connect with me on LinkedIn and say hi. Stay tuned for the next article.

GitHub SourceCode


Similar Articles