Tutorial

Flutter

Security

I Built this Without Deploying a Backend! A Step-by-Step Guide

I Built this Without Deploying a Backend! A Step-by-Step Guide

Jun 10, 2025

An AI Journal App Built using the Backendless Stack

⚠️ This is the hands-on follow-up to The Backendless Stack for Flutter Devs. Read that first if you want the why—this post is all about the how.


In this tutorial, we'll walk through building a production-ready Flutter app using the Backendless Stack:

  • 🪪 Supabase Auth for User Management

  • 🧠 Supabase Database for data

  • 🤖 OpenAI for AI Analysis

  • 🔐 Proxana for secure API calls and rate-limiting

All without writing or deploying a backend.

📱 App Idea: AI Journal (Feel free to change the app name/idea)

We're going to build a simple journaling app where:

  • Users can log in (optional)

  • They can write and edit journal entries.

  • AI suggests topics for entries.

  • AI analyzes entries and provides insights into your past and forecasted moods, as well as suggests topics for future entries.

  • Pro users receive unlimited rewrites, while Free users are limited to 3 per day.

🧭 Overview of the Build Process

Stage

What You'll Do

🔧 1. Project Setup

Create a new Flutter app and configure packages

🏗️ 2. Build the Layout & Theme

Build the layout that will hold the different screens, set up the navigation mechanism, and choose a color scheme.

📱 3. Screens, Models, and Services

Create the different screens, models, and services necessary to make the app functional.

🔗 4. Connecting It All

Connect the app to the actual database and services.

🔒 5. Securing our communications

Create a proxy to securely call third-party services without exposing the secret key.

🚀 7. Recap

A quick recap of what we achieved

Each step includes structure and intention. You'll fill in the code later.

🔧 1. Project Setup

  • Create a new Flutter project

  • Add required dependencies

  • Create a file structure (feel free to use your preferred file structure)


  • Create Supabase Project

Follow the guide from Supabase's Documentation.

🏗️ 2. Build the Layout & Theme

First, we need to decide what our application will look like, both in terms of component placement and themes. We're also going to configure the navigation in our app using the go_router package.


lib/utils/router.dart

final GoRouter router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const LayoutScreen(),
      routes: [
        GoRoute(
          path: 'login',
          builder: (context, state) => const LoginScreen(),
        ),
        GoRoute(
          path: 'register',
          builder: (context, state) => const RegisterScreen(),
        ),
        GoRoute(
          path: 'entry/:id',
          builder: (context, state) => EntryDetails(entryId: state.pathParameters['id']!),
        ),
        GoRoute(
          path: 'profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);


lib/main.dart

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'AI Journal',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.lightBlue,
          brightness: Brightness.dark,
        ),
        brightness: Brightness.dark,
      ),
      routerConfig: router,
    );
  }
}

You can modify the theme by changing the seedColor value to your desired color. As well as the brightness to switch between dark and light mode.

Layout Screen

The layout screen is considered a template, or a set of components that persist in each page. The set of components can include the AppBar or the BottomNavigationBar.


There are different ways to build a layout screen, but we chose the most straightforward approach of using a PageView to cycle between the various screens.


lib/features/layout/screens/layout_screen.dart

class LayoutScreen extends StatefulWidget {
  const LayoutScreen({super.key});

  @override
  State<LayoutScreen> createState() => _LayoutScreenState();
}

class _LayoutScreenState extends State<LayoutScreen> {
  int _currentIndex = 0;
  final PageController _pageController = PageController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("My Journal"),
        actions: [
          IconButton(
            icon: Icon(Icons.person),
            onPressed: () => context.go("/profile"),
          ),
        ],
      ),
      body: PageView(
        onPageChanged: (value) => setState(() => _currentIndex = value),
        controller: _pageController,
        children: [
          HomeScreen(),
          CreateEntryScreen(),
          AnalysisScreen(),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        showSelectedLabels: true,
        showUnselectedLabels: false,
        currentIndex: _currentIndex,
        iconSize: 32,
        onTap: (value) {
          setState(() => _currentIndex = value);
          _pageController.jumpToPage(value);
        },
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.home_outlined),
            activeIcon: Icon(Icons.home),
            label: "Home",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.add_circle_outline),
            activeIcon: Icon(Icons.add_circle),
            label: "Add Entry",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.self_improvement_outlined),
            activeIcon: Icon(Icons.self_improvement),
            label: "Analysis",
          ),
        ],
      ),
    );
  }
}


At this stage of the app, our app looks something like this.

Empty Screen With BottomNavigationBar and AppBar reading "My Journal"


📱 3. Screens, Models, and Services

Now that we have a place to house our screens, we'll start building the UI that allows users to view, create, and edit their journals. We will approach this by separating the different features our app has.

Features:

  1. Home: This is the home screen that shows us a list of journal entries

  2. Entries: This includes the "create entry" page, the data models for an entry, as well as the service that fetches the entries from our database.

  3. Auth: This includes the login and register screens, as well as the service that handles authenticating the user.

  4. Analysis: Our unique feature provides users with insight into the entries they create. This feature includes the analysis screen, as well as the service that uses OpenAI's API to perform the analysis.

🏠 Home

Our home screen will have a list of entries that the user previously created. The entries can be swiped to the left or right to delete them (a popup will be displayed confirming the deletion), as well as a "select mode" feature that allows users to long-press on an entry to select multiple entries and delete them in bulk.


lib/features/home/screens/home_screen.dart

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final Set<String> _selectedIds = {};
  bool _select = false;
  bool get _selectMode => _selectedIds.isNotEmpty || _select;

  void _onEntryLongPress(String id) {
    setState(() {
      if (!_selectedIds.contains(id)) {
        _selectedIds.add(id);
      }
    });
  }

  void _onEntryTap(String id, EntriesNotifier entriesProvider) {
    if (_selectMode) {
      setState(() {
        if (_selectedIds.contains(id)) {
          _selectedIds.remove(id);
        } else {
          _selectedIds.add(id);
        }
      });
    } else {
      context.push("/entry/$id");
    }
  }

  void _clearSelection() {
    setState(() {
      _selectedIds.clear();
      _select = false;
    });
  }

  void _deleteSelectedEntries(EntriesNotifier entriesProvider) async {
    if (_selectedIds.isEmpty) return;
    final confirmed = await _confirmDelete(context, count: _selectedIds.length);
    if (confirmed) {
      final idsToDelete = List<String>.from(_selectedIds);
      for (String id in idsToDelete) {
        entriesProvider.deleteEntry(id);
      }
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('${idsToDelete.length} entr${idsToDelete.length == 1 ? 'y' : 'ies'} deleted'),
          ),
        );
      }
      setState(() {
        _selectedIds.clear();
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final entriesProvider = EntriesProvider.of(context);
    final entries = entriesProvider.entries;

    return Column(
      children: [
        Row(
          mainAxisAlignment: _selectMode ? MainAxisAlignment.spaceBetween : MainAxisAlignment.end,
          children: [
            if (_selectMode) ...[
              IconButton(
                icon: const Icon(Icons.close),
                onPressed: _clearSelection,
              ),
              Text('${_selectedIds.length} selected'),
              IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () => _deleteSelectedEntries(entriesProvider),
              ),
            ],
            if (!_selectMode) ...[
              IconButton(
                icon: const Icon(Icons.check_circle_outline),
                onPressed: () => setState(() {
                  _select = !_select;
                  if (!_select) _clearSelection();
                }),
              ),
            ],
          ],
        ),
        Expanded(
          child: ListView.builder(
            shrinkWrap: true,
            itemCount: entries.length,
            itemBuilder: (context, index) {
              final entry = entries[index];
              final isSelected = _selectedIds.contains(entry.id);

              return GestureDetector(
                onLongPress: () => _onEntryLongPress(entry.id),
                onTap: () => _onEntryTap(entry.id, entriesProvider),
                child: Dismissible(
                  key: ValueKey(entry.id),
                  direction: _selectMode ? DismissDirection.none : DismissDirection.endToStart,
                  confirmDismiss: (direction) async {
                    if (_selectMode) return false; // Disable dismiss while in select mode
                    return _confirmDelete(context);
                  },
                  onDismissed: (direction) {
                    if (!_selectMode) {
                      entriesProvider.deleteEntry(entry.id);
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(content: Text('Entry deleted')),
                      );
                    }
                  },
                  background: Container(
                    alignment: Alignment.centerRight,
                    padding: const EdgeInsets.symmetric(horizontal: 24),
                    color: Colors.red,
                    child: const Icon(Icons.delete, color: Colors.white, size: 32),
                  ),
                  child: Card(
                    margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                    elevation: 2,
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(12),
                      side: isSelected ? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2) : BorderSide.none,
                    ),
                    color: isSelected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) : null,
                    child: ListTile(
                      contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
                      leading: _selectMode
                          ? Checkbox(
                              value: isSelected,
                              onChanged: (bool? value) {
                                _onEntryTap(entry.id, entriesProvider);
                              },
                            )
                          : null,
                      title: Text(
                        entry.title ?? 'Untitled',
                        style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                      subtitle: Text(
                        entry.content.length > 50 ? '${entry.content.substring(0, 50)}...' : entry.content,
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      trailing: _selectMode ? null : const Icon(Icons.chevron_right),
                      // onTap is handled by GestureDetector
                    ),
                  ),
                ),
              );
            },
          ),
        ),
      ],
    );
  }

  Future<bool> _confirmDelete(BuildContext context, {int count = 1}) async =>
      await showDialog<bool>(
        context: context,
        builder: (context) => AlertDialog(
          title: Text('Delete ${count > 1 ? '$count Entries' : 'Entry'}'),
          content: Text('Are you sure you want to delete ${count > 1 ? 'these $count entries' : 'this entry'}?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(false),
              child: Text('Cancel'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.of(context).pop(true),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.red,
              ),
              child: Text('Delete'),
            ),
          ],
        ),
      ) ??
      false;
}


Now our app will look something like this:


Pretty cool, right? Our home screen is now complete, and we can proceed to the next step.

📝 Journal Entries

In this section, we'll create the "Create Entry" screen, define the data model for a single entry, and write the service that stores, fetches, updates, or deletes an entry. Let's start with the model.

Model


lib/features/entries/models/entry.dart

class Entry {
  Entry({
    required this.id,
    this.title,
    required this.content,
    required this.date,
  });

  final String id;
  final String? title;
  final String content;
  final DateTime date;

  /// Factory constructor to create an Entry from a map
  factory Entry.fromJson(Map<String, dynamic> map) {
    return Entry(
      id: map['id'] as String,
      title: map['title'] as String?,
      content: map['content'] as String,
      date: DateTime.parse(map['date'] as String),
    );
  }

  /// Method to convert an Entry to a map
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'content': content,
      'date': date.toIso8601String(),
    };
  }

  Entry copyWith({String? title, String? content}) => Entry(
        id: id,
        title: title ?? this.title,
        content: content ?? this.content,
        date: date,
      );
}

Our model includes all the necessary fields and some helper functions to facilitate easier manipulation of the model.

✨ Service & State Management

We need to create a service that will fetch, create, update, or delete entries from our database. We also need to create a state management layer that updates the UI when the underlying data changes.


The way we're going to build our services is by first creating an abstract (or interface) of the methods we want our service to implement, and then creating an implementation of the service.


The reason we chose this approach is that we need to be able to quickly test the services without first setting up a remote database.


In this instance, we create a mock entries service (or data source) that stores entries in the device's local storage, making it very easy to test and debug our code. Later on, we will create another implementation of the entry source that fetches real data from Supabase.

🛎️ Service Layer


lib/features/entries/sources/entry_source.dart

abstract class EntriesSource {
  /// Returns a list of entries.
  Future<List<Entry>> getEntries({int? skip, int? limit});

  /// Returns a single entry by its ID.
  Future<Entry?> getEntry(String id);

  /// Adds a new entry. Returns the ID of the newly created entry.
  Future<Entry> addEntry({String? title, required String content});

  /// Updates an existing entry.
  Future<void> updateEntry(Entry entry);

  /// Deletes an entry by its ID.
  Future<void> deleteEntry(String id);
}

class MockEntriesSource implements EntriesSource {
  MockEntriesSource() {
    _ensureInitialized();
  }

  SharedPreferences? _sharedPreferences;
  bool get _initialized => _sharedPreferences != null;

  @override
  Future<Entry> addEntry({String? title, required String content}) async {
    await _ensureInitialized();

    final entry = Entry(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
      content: content,
      date: DateTime.now(),
    );

    // Convert the entry to JSON and save it in SharedPreferences
    final entries = _sharedPreferences!.getStringList('entries') ?? [];
    entries.add(jsonEncode(entry.toJson()));
    await _sharedPreferences!.setStringList('entries', entries);

    return entry;
  }

  @override
  Future<void> deleteEntry(String id) async {
    await _ensureInitialized();

    // Retrieve the current entries
    final entries = _sharedPreferences!.getStringList('entries') ?? [];

    // Filter out the entry with the specified ID
    final updatedEntries = entries.where((entry) => Entry.fromJson(jsonDecode(entry)).id != id).toList();

    // Save the updated entries back to SharedPreferences
    await _sharedPreferences!.setStringList('entries', updatedEntries);
  }

  @override
  Future<List<Entry>> getEntries({int? skip, int? limit}) async {
    await _ensureInitialized();

    // Retrieve the entries from SharedPreferences
    final entries = _sharedPreferences!.getStringList('entries') ?? [];

    // Convert the JSON strings back to Entry objects
    return entries.map((entry) => Entry.fromJson(jsonDecode(entry))).toList();
  }

  @override
  Future<Entry?> getEntry(String id) async {
    await _ensureInitialized();

    // Retrieve the entries from SharedPreferences
    final entries = _sharedPreferences!.getStringList('entries') ?? [];

    // Find the entry with the specified ID
    final entryJson = entries.firstWhere(
      (entry) => Entry.fromJson(jsonDecode(entry)).id == id,
      orElse: () => '',
    );

    // If no entry was found, return null
    if (entryJson.isEmpty) {
      return null;
    }

    // Convert the JSON string back to an Entry object
    return Entry.fromJson(jsonDecode(entryJson));
  }

  @override
  Future<void> updateEntry(Entry entry) async {
    await _ensureInitialized();

    // Retrieve the current entries
    final entries = _sharedPreferences!.getStringList('entries') ?? [];

    // Find the index of the entry to update
    final index = entries.indexWhere((e) => Entry.fromJson(jsonDecode(e)).id == entry.id);

    if (index != -1) {
      // Update the entry at the found index
      entries[index] = jsonEncode(entry.toJson());
      await _sharedPreferences!.setStringList('entries', entries);
    }
  }

  Future<void> _init() async => _sharedPreferences ??= await SharedPreferences.getInstance();

  Future<void> _ensureInitialized() async {
    if (!_initialized) {
      await _init();
    }
  }
}


Our services should be accessible from anywhere in our app. This means we need a service locator where we can register our services and then retrieve them from anywhere in our app. For this, we use the package get_it .

Below is our straightforward approach to registering our services and looking them up later.


lib/utils/services.dart

final GetIt services = GetIt.instance;

void initServices() {
  services.registerLazySingleton<EntriesSource>(() => EntriesSourceImpl());
  // Other services here...
}

We define a top-level variable that stores the service locator's instance (we do this to make our code more readable), and we also define a top-level function that registers the services. We call the initServices() function in our main function before the runApp statement.


Now, whenever we need a service, we can do this:

services.get<EntriesSource>();

We will then obtain an instance of the service.


🤝 State Management Layer (Provider)

Unlike the service layer, the state management layer is responsible for updating the UI with the fetched data from the service layer. For the most straightforward approach and the least boilerplate, we decided to go with the provider package which makes state management a breeze.

In the code below, you will notice we have two classes. A notifier class and a provider class both have different responsibilities.

The notifier class holds the state that the UI should display (loading, idle, error, etc.) and also stores the data fetched from the data source. The notifier class also notifies the UI to refresh (or rebuild) itself, displaying the change in state or data.

The provider class is syntactic sugar that makes our code more readable and maintainable. Its sole purpose is to inject the notifier class into the widget tree, and it also provides some helper functions that make it easier to find our notifier class.


lib/features/entries/providers/entries_provider.dart

enum EntriesState { none, loading, error }

class EntriesNotifier extends ChangeNotifier {
  EntriesNotifier() {
    loadEntries();
  }

  final EntriesSource source = services.get<EntriesSource>();

  List<Entry> _entries = [];
  List<Entry> get entries => _entries;

  EntriesState _state = EntriesState.none;
  EntriesState get state => _state;

  String? _errorMessage;
  String? get errorMessage => _state == EntriesState.error ? _errorMessage : null;

  Future<void> loadEntries() async {
    try {
      _setState(EntriesState.loading);
      _entries = await source.getEntries();
      _setState(EntriesState.none);
    } catch (e) {
      _errorMessage = e.toString();
      _setState(EntriesState.error);
    }
  }

  Future<String?> addEntry({String? title, required String content}) async {
    try {
      _setState(EntriesState.loading);
      final entry = await source.addEntry(title: title, content: content);
      await loadEntries();
      _setState(EntriesState.none);
      return entry.id;
    } catch (e) {
      _errorMessage = e.toString();
      _setState(EntriesState.error);
      return null;
    }
  }

  Future<void> updateEntry(Entry entry) async {
    try {
      _setState(EntriesState.loading);
      await source.updateEntry(entry);
      await loadEntries();
      _setState(EntriesState.none);
    } catch (e) {
      _errorMessage = e.toString();
      _setState(EntriesState.error);
    }
  }

  Future<void> deleteEntry(String id) async {
    try {
      _setState(EntriesState.loading);
      await source.deleteEntry(id);
      await loadEntries();
      _setState(EntriesState.none);
    } catch (e) {
      _errorMessage = e.toString();
      _setState(EntriesState.error);
    }
  }

  void _setState(EntriesState state) {
    if (_state != state) {
      _state = state;
      notifyListeners();
    }
  }
}

class EntriesProvider extends StatelessWidget {
  const EntriesProvider({super.key, this.child});

  static EntriesNotifier of(BuildContext context, {bool listen = true}) => Provider.of<EntriesNotifier>(context, listen: listen);

  final Widget? child;

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProxyProvider<AuthNotifier, EntriesNotifier>(
      create: (context) => EntriesNotifier(),
      update: (_, auth, entries) => entries!..loadEntries(),
      child: child != null ? Builder(builder: (context) => child!) : child,
    );
  }
}

And now, we can go back to our main file and wrap our root widget with the EntriesProvider.


lib/main.dart

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

  @override
  Widget build(BuildContext context) {
    return EntriesProvider(
      child: MaterialApp.router(
        title: 'AI Journal',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.lightBlue,
            brightness: Brightness.dark,
          ),
          brightness: Brightness.dark,
        ),
        routerConfig: router,
      ),
    );
  }
}


📱 Screens

This feature requires two screens:

  1. Create Entry: We will display this screen in the layout screen. It will include two fields, the title field and the story field.

  2. Entry Details: This screen will be displayed outside (or above) the layout, since we don't want the app bar or the bottom navigation to be visible. This screen allows the user to view the full entry and edit it.


lib/features/entries/screens/create_entry_screen.dart

class CreateEntryScreen extends StatefulWidget {
  const CreateEntryScreen({super.key});

  @override
  State<CreateEntryScreen> createState() => _CreateEntryScreenState();
}

class _CreateEntryScreenState extends State<CreateEntryScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Form(
      key: _formKey,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          spacing: 16,
          children: [
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text("Title", style: theme.textTheme.headlineMedium),
                TextFormField(
                  controller: _titleController,
                  decoration: InputDecoration(
                    hintText: "Add a title to this entry",
                    border: InputBorder.none,
                  ),
                ),
              ],
            ),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text("Story", style: theme.textTheme.headlineMedium),
                  Expanded(
                    child: TextFormField(
                      controller: _contentController,
                      maxLines: null,
                      expands: true,
                      decoration: InputDecoration(
                        hintText: "Write your thoughts here...",
                        border: InputBorder.none,
                      ),
                      validator: Validators.requiredField,
                    ),
                  ),
                ],
              ),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                FilledButton(
                  onPressed: () async {
                    if (!_formKey.currentState!.validate()) return;
                    final provider = EntriesProvider.of(context, listen: false);
                    final title = _titleController.text.trim();
                    final content = _contentController.text.trim();

                    final id = await provider.addEntry(content: content, title: title);
                    if (id == null) return;
                    _formKey.currentState!.reset();
                    if (context.mounted) context.push('/entry/$id');
                  },
                  child: Text('Submit'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}


lib/features/entries/screens/entry_details.dart

class EntryDetails extends StatefulWidget {
  const EntryDetails({super.key, required this.entryId});

  final String entryId;

  @override
  State<EntryDetails> createState() => _EntryDetailsState();
}

class _EntryDetailsState extends State<EntryDetails> {
  final EntriesSource _source = services.get<EntriesSource>();
  bool _loading = false;
  Entry? _entry;

  // Editing state
  bool _editing = false;
  late TextEditingController _titleController;
  late TextEditingController _contentController;

  @override
  void initState() {
    super.initState();
    _loadEntry();
  }

  void _loadEntry() async {
    setState(() => _loading = true);
    _entry = await _source.getEntry(widget.entryId);
    if (_entry != null) {
      _titleController = TextEditingController(text: _entry!.title ?? "Untitled");
      _contentController = TextEditingController(text: _entry!.content);
    }
    setState(() => _loading = false);
  }

  void _toggleEdit() {
    setState(() {
      if (_editing) {
        // Save changes
        _entry = _entry!.copyWith(
          title: _titleController.text,
          content: _contentController.text,
        );
        // Optionally, persist changes to backend here
        EntriesProvider.of(context, listen: false).updateEntry(_entry!);
      }
      _editing = !_editing;
    });
  }

  @override
  void dispose() {
    if (_entry != null) {
      _titleController.dispose();
      _contentController.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_entry?.title ?? "Untitled"),
        actions: [
          if (_entry != null)
            IconButton(
              icon: Icon(_editing ? Icons.save : Icons.edit),
              tooltip: _editing ? "Save" : "Edit",
              onPressed: _toggleEdit,
            ),
        ],
      ),
      body: _loading
          ? Center(child: CircularProgressIndicator())
          : _entry == null
              ? Center(child: Text("Entry not found"))
              : _buildDetails(),
    );
  }

  Widget _buildDetails() {
    final formattedDate = _entry != null ? DateFormat.yMMMMEEEEd().add_jm().format(_entry!.date) : '';
    final theme = Theme.of(context);
    return SingleChildScrollView(
      child: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (_editing) Text("Title", style: theme.textTheme.headlineMedium),
            _editing
                ? TextField(
                    controller: _titleController,
                    style: Theme.of(context).textTheme.headlineMedium,
                    decoration: InputDecoration(
                      hintText: "Add a title to this entry",
                      border: InputBorder.none,
                    ),
                  )
                : Text(
                    _entry!.title ?? "Untitled",
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
            if (!_editing) ...[
              SizedBox(height: 12),
              Row(
                children: [
                  Icon(Icons.calendar_today, size: 18, color: Colors.grey[600]),
                  SizedBox(width: 8),
                  Text(
                    formattedDate,
                    style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey[700]),
                  ),
                ],
              ),
            ],
            SizedBox(height: 24),
            if (_editing) Text("Story", style: theme.textTheme.headlineMedium),
            _editing
                ? TextField(
                    controller: _contentController,
                    maxLines: null,
                    minLines: 8,
                    decoration: InputDecoration(
                      hintText: "Write your thoughts here...",
                      border: InputBorder.none,
                    ),
                  )
                : Text(
                    _entry!.content,
                    style: Theme.of(context).textTheme.bodyLarge,
                  ),
          ],
        ),
      ),
    );
  }
}


The above code will look something like this:

Create Entry

Entry Details

🔒 Auth

Following the same approach as before, we'll quickly create the login page, the register page, the auth service, and the necessary models.


lib/features/auth/auth_service.dart

// User model
class User {
  final String id;
  final String email;
  final String token;

  User({required this.id, required this.email, required this.token});
}

// AuthService abstract class
abstract class AuthService {
  Future<User?> signInWithEmailPassword(String email, String password);
  Future<User?> signUpWithEmailPassword(String email, String password);
  Future<void> signOut();
  User? get currentUser;
  Stream<User?> get authStateChanges;
  void dispose(); // For implementations that need cleanup
}

class MockAuthService implements AuthService {
  User? _currentUser;
  final StreamController<User?> _authStateController = StreamController<User?>.broadcast();

  MockAuthService() {
    // Initialize with current user null
    _currentUser = null;
    // Ensure the stream emits the initial state.
    // Add to stream after a microtask to allow listeners to subscribe first if needed,
    // or directly if initial state is critical for immediate consumers.
    Future.microtask(() => _authStateController.add(_currentUser));
  }

  @override
  User? get currentUser => _currentUser;

  @override
  Stream<User?> get authStateChanges => _authStateController.stream;

  @override
  Future<User?> signInWithEmailPassword(String email, String password) async {
    await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
    if (email == 'test@example.com' && password == 'password') {
      _currentUser = User(id: '123', email: email, token: 'mock');
      _authStateController.add(_currentUser);
      return _currentUser;
    } else {
      // For failed sign-in, ensure user is null if it changed, and stream reflects this.
      bool userWasNonNull = _currentUser != null;
      _currentUser = null;
      if (userWasNonNull) {
        // Only add to stream if state actually changed to null
        _authStateController.add(null);
      }
      throw Exception('Invalid email or password');
    }
  }

  @override
  Future<User?> signUpWithEmailPassword(String email, String password) async {
    await Future.delayed(const Duration(seconds: 1));
    // Simulate new user creation
    _currentUser = User(id: DateTime.now().millisecondsSinceEpoch.toString(), email: email, token: 'mock');
    _authStateController.add(_currentUser);
    return _currentUser;
  }

  @override
  Future<void> signOut() async {
    await Future.delayed(const Duration(seconds: 1));
    if (_currentUser != null) {
      _currentUser = null;
      _authStateController.add(null);
    }
  }

  @override
  void dispose() {
    _authStateController.close();
  }
}


lib/features/auth/auth_provider.dart

enum AuthOperationStatus { idle, loading, error }

class AuthNotifier extends ChangeNotifier {
  final AuthService _authService = services.get<AuthService>();
  StreamSubscription<User?>? _userSubscription;

  User? _currentUser;
  User? get currentUser => _currentUser;

  bool get isAuthenticated => _currentUser != null;

  AuthOperationStatus _status = AuthOperationStatus.idle;
  AuthOperationStatus get status => _status;

  String? _errorMessage;
  String? get errorMessage => _errorMessage;

  AuthNotifier() {
    _currentUser = _authService.currentUser; // Initialize with current user
    _userSubscription = _authService.authStateChanges.listen(
      (user) {
        _currentUser = user;
        // If an operation was loading, it means it completed successfully.
        if (_status == AuthOperationStatus.loading) {
          _status = AuthOperationStatus.idle;
          _errorMessage = null; // Clear any error from a previous failed attempt
        }
        notifyListeners();
      },
      onError: (error) {
        // This typically handles errors on the stream itself.
        _currentUser = null;
        _status = AuthOperationStatus.error;
        _errorMessage = "Auth stream error: ${error.toString()}";
        notifyListeners();
      },
    );
  }

  Future<void> signIn(String email, String password) async {
    await _performAuthOperation(
      () => _authService.signInWithEmailPassword(email, password),
    );
  }

  Future<void> signUp(String email, String password) async {
    await _performAuthOperation(
      () => _authService.signUpWithEmailPassword(email, password),
    );
  }

  Future<void> signOut() async {
    await _performAuthOperation(
      () => _authService.signOut(),
    );
  }

  Future<void> _performAuthOperation(Future<dynamic> Function() operation) async {
    _status = AuthOperationStatus.loading;
    _errorMessage = null;
    notifyListeners();

    try {
      await operation();
    } catch (e) {
      _status = AuthOperationStatus.error;
      _errorMessage = e.toString().replaceFirst("Exception: ", "");
      notifyListeners();
    }
  }

  @override
  void dispose() {
    _userSubscription?.cancel();
    super.dispose();
  }
}

class AuthProvider extends StatelessWidget {
  const AuthProvider({super.key, this.child});

  final Widget? child;

  static AuthNotifier of(BuildContext context, {bool listen = true}) => Provider.of<AuthNotifier>(context, listen: listen);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AuthNotifier>


We created an auth screen that houses all the repeated components between the login and register pages. We pass the desired form to the auth screen, and it will be displayed. This approach is similar to the layout screen we created earlier.


lib/features/auth/screens/auth_screen.dart

class AuthScreen extends StatelessWidget {
  const AuthScreen({
    super.key,
    required this.formWidget,
  });

  final Widget formWidget;

  @override
  Widget build(BuildContext context) {
    final screenHeight = MediaQuery.of(context).size.height;
    final screenWidth = MediaQuery.of(context).size.width;

    return Scaffold(
      appBar: AppBar(),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24.0),
          child: SingleChildScrollView(
            child: IntrinsicHeight(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                spacing: 48,
                children: <Widget>[
                  Expanded(
                    flex: 2,
                    child: Container(
                      alignment: Alignment.center,
                      child: SvgPicture.asset(
                        'assets/login.svg', // Ensure this path is correct and the asset is in pubspec.yaml
                        height: screenHeight * 0.3,
                        width: screenWidth * 0.7,
                        fit: BoxFit.contain,
                      ),
                    ),
                  ),
                  Text(
                    "Welcome to AI Journal",
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                  formWidget,
                  const SizedBox(height: 24.0), // Added some bottom padding
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}


And then the login and register page will use the auth screen layout to build the final page.


lib/features/auth/screens/login_screen.dart

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  late final authState = AuthProvider.of(context);
  bool get _isLoading => authState.status == AuthOperationStatus.loading;
  String? get _errorMessage => authState.errorMessage;

  @override
  Widget build(BuildContext context) {
    return AuthScreen(
      formWidget: Form(
        key: _formKey,
        child: Column(
          spacing: 20,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              "Please log in to continue",
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            if (_errorMessage != null)
              Text(
                _errorMessage!,
                style: TextStyle(color: Theme.of(context).colorScheme.error),
              ),
            TextFormField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: "Email",
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.emailAddress,
              validator: Validators.validateEmail,
            ),
            TextFormField(
              controller: _passwordController,
              decoration: InputDecoration(
                labelText: "Password",
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              validator: Validators.validatePassword,
            ),
            FilledButton(
              onPressed: !_isLoading
                  ? () async {
                      if (!_formKey.currentState!.validate()) return;
                      await authState.signIn(_emailController.text, _passwordController.text);
                    }
                  : null,
              child: !_isLoading ? const Text("Log In") : CircularProgressIndicator(),
            ),
            TextButton(
              onPressed: () => context.push("/register"),
              child: const Text("Don't have an account? Register"),
            ),
          ],
        ),
      ),
    );
  }
}


lib/features/auth/screens/register_screen.dart

class RegisterScreen extends StatefulWidget {
  const RegisterScreen({super.key});

  @override
  State<RegisterScreen> createState() => _RegisterScreenState();
}

class _RegisterScreenState extends State<RegisterScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  late final authState = AuthProvider.of(context);
  bool get _isLoading => authState.status == AuthOperationStatus.loading;
  String? get _errorMessage => authState.errorMessage;

  @override
  Widget build(BuildContext context) {
    return AuthScreen(
      formWidget: Form(
        key: _formKey,
        child: Column(
          spacing: 20,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              "Create a new account",
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            if (_errorMessage != null)
              Text(
                _errorMessage!,
                style: TextStyle(color: Theme.of(context).colorScheme.error),
              ),
            TextFormField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: "Email",
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.emailAddress,
              validator: Validators.validateEmail,
            ),
            TextFormField(
              controller: _passwordController,
              decoration: InputDecoration(
                labelText: "Password",
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              validator: Validators.validatePassword,
            ),
            TextFormField(
              controller: _confirmPasswordController,
              decoration: InputDecoration(
                labelText: "Confirm Password",
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              validator: (value) => Validators.validateConfirmPassword(value, _passwordController.text),
            ),
            FilledButton(
              onPressed: !_isLoading
                  ? () async {
                      if (!_formKey.currentState!.validate()) return;
                      await AuthProvider.of(context, listen: false).signUp(_emailController.text, _passwordController.text);
                    }
                  : null,
              child: !_isLoading ? const Text("Register") : const CircularProgressIndicator(),
            ),
            TextButton(
              onPressed: () => context.pop(),
              child: const Text("Already have an account? Log In"),
            ),
          ],
        ),
      ),
    );
  }
}

🤖 AI Analysis

Again, following the same process as before, we will create the necessary screens, models, and services.


lib/features/analysis/screens/analysis_screen.dart

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

  String _getDynamicContentForCard(
    AnalysisNotifier notifier,
    AnalysisType type,
    String cardTitle,
    String originalPlaceholderContent,
  ) {
    final status = notifier.getStatus(type);
    final analysis = notifier.getAnalysis(type);

    switch (status) {
      case AnalysisOperationStatus.idle:
        return "Analysis for '$cardTitle' will appear here. Loading...";
      case AnalysisOperationStatus.loading:
        return "Generating analysis for '$cardTitle'...";
      case AnalysisOperationStatus.success:
        return analysis?.content ?? "Successfully analyzed '$cardTitle', but content is missing.";
      case AnalysisOperationStatus.error:
        final error = notifier.getError(type);
        return "Error generating analysis for '$cardTitle': ${error ?? "Unknown error"}";
    }
  }

  @override
  Widget build(BuildContext context) {
    final authProvider = AuthProvider.of(context);

    if (authProvider.currentUser == null) {
      return Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          spacing: 20,
          children: [
            Text(
              'Please log in to view your journal analysis.',
              style: Theme.of(context).textTheme.bodyLarge,
              textAlign: TextAlign.center,
            ),
            FilledButton(
              onPressed: () => context.go("/login"),
              child: const Text('Log In'),
            ),
          ],
        ),
      );
    }

    final entriesProvider = EntriesProvider.of(context);

    if (entriesProvider.entries.length < 5) {
      return Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            'You need at least 5 entries to generate meaningful analysis. Keep writing!',
            style: Theme.of(context).textTheme.bodyLarge,
            textAlign: TextAlign.center,
          ),
        ),
      );
    }

    // If all checks pass, use AnalysisProvider
    final analysisNotifier = AnalysisProvider.of(context);
    analysisNotifier.fetchAllAnalysesIfNeeded();

    const String pastWeekTitle = 'Past Week Analysis';
    const String futureInsightsTitle = 'Future Insights & Outlook';
    const String suggestionsTitle = 'Suggested Writing Topics';

    // Original placeholder content
    const String pastWeekPlaceholder =
        'Here\'s a summary of your emotional trends, key themes, and notable moments from your journal entries over the last seven days. Understanding your past week can help you identify patterns and reflect on your progress.';
    const String futureInsightsPlaceholder =
        'Based on your recent entries, here are some potential areas for growth, things to be mindful of, or positive developments to look forward to. These insights aim to help you navigate the upcoming days with more awareness.';
    const String suggestionsPlaceholder =
        'Feeling stuck on what to write? Here are a few AI-generated prompts and topics tailored to your recent journaling themes. Exploring these suggestions might unlock new perspectives or help you delve deeper into your thoughts.';

    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: ListView(
        children: <Widget>[
          _AnalysisCard(
            title: pastWeekTitle,
            icon: Icons.history_edu_outlined,
            color: Colors.blue.shade100,
            content: _getDynamicContentForCard(
              analysisNotifier,
              AnalysisType.pastWeek,
              pastWeekTitle,
              pastWeekPlaceholder,
            ),
          ),
          const SizedBox(height: 16),
          _AnalysisCard(
            title: futureInsightsTitle,
            icon: Icons.lightbulb_outline_rounded,
            color: Colors.orange.shade100,
            content: _getDynamicContentForCard(
              analysisNotifier,
              AnalysisType.futureInsights,
              futureInsightsTitle,
              futureInsightsPlaceholder,
            ),
          ),
          const SizedBox(height: 16),
          _AnalysisCard(
            title: suggestionsTitle,
            icon: Icons.edit_note_outlined,
            color: Colors.green.shade100,
            content: _getDynamicContentForCard(
              analysisNotifier,
              AnalysisType.suggestions,
              suggestionsTitle,
              suggestionsPlaceholder,
            ),
          ),
          const SizedBox(height: 16),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 20.0),
            child: Text(
              'Note: Analysis is generated based on your journal entries and may take a moment to appear.',
              style: Theme.of(context).textTheme.bodyMedium,
              textAlign: TextAlign.center,
            ),
          ),
          const SizedBox(height: 16),
          FilledButton.icon(
            onPressed: analysisNotifier.isAnyLoading ? null : () => analysisNotifier.reanalyzeEntries(),
            label: const Text('Reanalyze'),
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
    );
  }
}

class _AnalysisCard extends StatelessWidget {
  const _AnalysisCard({
    required this.title,
    required this.icon,
    required this.color,
    required this.content,
  });

  final String title;
  final IconData icon;
  final Color color;
  final String content;

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return Hero(
      tag: title, // Unique tag for the Hero animation
      child: Card(
        elevation: 4.0,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12.0),
        ),
        clipBehavior: Clip.antiAlias,
        color: color,
        child: InkWell(
          onTap: () {
            Navigator.of(context).push(
              PageRouteBuilder(
                fullscreenDialog: true,
                barrierDismissible: true,
                opaque: false,
                pageBuilder: (context, _, __) => _AnalysisDetailScreen(
                  title: title,
                  icon: icon,
                  fullContent: content,
                  cardColor: color,
                ),
              ),
            );
          },
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Icon(icon, size: 30.0, color: theme.colorScheme.primaryContainer),
                    const SizedBox(width: 12.0),
                    Expanded(
                      child: Text(
                        title,
                        style: theme.textTheme.titleLarge?.copyWith(
                          fontWeight: FontWeight.bold,
                          color: theme.colorScheme.onInverseSurface.withAlpha((0.87 * 255).round()),
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 10.0),
                MarkdownBlock(
                  data: content.length > 50 ? '${content.substring(0, 50)}...' : content,
                  selectable: false,
                  config: MarkdownConfig(
                    configs: [
                      HrConfig(color: HrConfig.darkConfig.color.withAlpha((0.87 * 255).round())),
                      H1Config(style: _darkenColor(H1Config.darkConfig.style, theme)),
                      H2Config(style: _darkenColor(H2Config.darkConfig.style, theme)),
                      H3Config(style: _darkenColor(H3Config.darkConfig.style, theme)),
                      H4Config(style: _darkenColor(H4Config.darkConfig.style, theme)),
                      H5Config(style: _darkenColor(H5Config.darkConfig.style, theme)),
                      H6Config(style: _darkenColor(H6Config.darkConfig.style, theme)),
                      PreConfig(textStyle: _darkenColor(PreConfig.darkConfig.textStyle, theme)),
                      PConfig(textStyle: _darkenColor(PConfig.darkConfig.textStyle, theme)),
                      CodeConfig(style: _darkenColor(CodeConfig.darkConfig.style, theme)),
                      BlockquoteConfig(textColor: BlockquoteConfig.darkConfig.textColor.withAlpha((0.87 * 255).round())),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  TextStyle _darkenColor(TextStyle style, ThemeData theme) => style.copyWith(color: theme.colorScheme.primaryContainer);
}

class _AnalysisDetailScreen extends StatelessWidget {
  final String title;
  final IconData icon;
  final String fullContent;
  final Color cardColor;

  const _AnalysisDetailScreen({
    required this.title,
    required this.icon,
    required this.fullContent,
    required this.cardColor,
  });

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return Scaffold(
      backgroundColor: cardColor.withAlpha((0.05 * 255).round()),
      body: SafeArea(
        bottom: false,
        child: Hero(
          tag: title, // Same tag as the card
          child: Material(
            // Material widget is needed for Hero transition of non-Card elements or for consistent appearance
            type: MaterialType.card, // Makes it behave like a Card for elevation and shape
            color: cardColor, // Use the cardColor for the Material widget
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12.0),
            ),
            clipBehavior: Clip.antiAlias,
            elevation: 0.0,
            child: Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                spacing: 20,
                children: [
                  Row(
                    children: [
                      Icon(icon, size: 30.0, color: theme.colorScheme.primaryContainer),
                      const SizedBox(width: 12.0),
                      Expanded(
                        child: Text(
                          title,
                          style: theme.textTheme.headlineSmall?.copyWith(
                            fontWeight: FontWeight.bold,
                            color: theme.colorScheme.onInverseSurface.withAlpha((0.87 * 255).round()),
                          ),
                        ),
                      ),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.end,
                        children: [
                          CloseButton(color: theme.colorScheme.onInverseSurface),
                        ],
                      ),
                    ],
                  ),
                  Expanded(
                    // Use Expanded to make SingleChildScrollView take available space
                    child: SingleChildScrollView(
                      child: MarkdownBlock(
                        data: fullContent,
                        selectable: false,
                        config: MarkdownConfig(
                          configs: [
                            HrConfig(color: HrConfig.darkConfig.color.withAlpha((0.87 * 255).round())),
                            H1Config(style: _darkenColor(H1Config.darkConfig.style, theme)),
                            H2Config(style: _darkenColor(H2Config.darkConfig.style, theme)),
                            H3Config(style: _darkenColor(H3Config.darkConfig.style, theme)),
                            H4Config(style: _darkenColor(H4Config.darkConfig.style, theme)),
                            H5Config(style: _darkenColor(H5Config.darkConfig.style, theme)),
                            H6Config(style: _darkenColor(H6Config.darkConfig.style, theme)),
                            PreConfig(textStyle: _darkenColor(PreConfig.darkConfig.textStyle, theme)),
                            PConfig(textStyle: _darkenColor(PConfig.darkConfig.textStyle, theme)),
                            CodeConfig(style: _darkenColor(CodeConfig.darkConfig.style, theme)),
                            BlockquoteConfig(textColor: BlockquoteConfig.darkConfig.textColor.withAlpha((0.87 * 255).round())),
                          ],
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  TextStyle _darkenColor(TextStyle style, ThemeData theme) => style.copyWith(color: theme.colorScheme.primaryContainer);
}


lib/features/analysis/analysis_service.dart

enum AnalysisType { pastWeek, futureInsights, suggestions }

class AnalysisResult {
  AnalysisResult({
    this.id,
    required this.content,
    required this.type,
    required this.timestamp,
  });

  factory AnalysisResult.fromJson(Map<String, dynamic> json) {
    return AnalysisResult(
      id: json['id'] as String?,
      content: json['content'] as String,
      type: AnalysisType.values.firstWhere((e) => e.toString() == 'AnalysisType.${json['type']}'),
      timestamp: DateTime.parse(json['timestamp'] as String),
    );
  }

  final String? id;
  final String content;
  final AnalysisType type;
  final DateTime timestamp;

  Map<String, dynamic> toJson() {
    return {
      'content': content,
      'type': type.name,
      'timestamp': timestamp.toIso8601String(),
    };
  }
}

abstract class AnalysisService {
  Future<AnalysisResult?> analyzeEntries({required AnalysisType type, int limit = 10});

  Future<AnalysisResult?> getLatestAnalysis({required AnalysisType type});
}

class AnalysisServiceImpl implements AnalysisService {
  final _entriesSource = services.get<EntriesSource>();
  final _openAIClient = OpenAIClient(baseUrl: openAIProxyUrl, apiKey: supabase.auth.currentSession?.accessToken);

  @override
  Future<AnalysisResult?> analyzeEntries({required AnalysisType type, int limit = 10}) async {
    // First we collect the entries based on the date range and limit
    final entries = await _entriesSource.getEntries(skip: 0, limit: limit);

    if (entries.isEmpty) return null;

    // Call OpenAI
    final response = await _openAIClient.createChatCompletion(
      request: CreateChatCompletionRequest(
        model: ChatCompletionModel.model(ChatCompletionModels.gpt4oMini),
        messages: _buildMessages(entries, type),
      ),
    );

    final choice = response.choices.firstOrNull;

    if (choice == null || choice.message.content?.isEmpty == true) return null;

    var result = AnalysisResult(
      content: choice.message.content!,
      type: type,
      timestamp: DateTime.now(),
    );

    // Save the analysis result to the database
    await supabase.from('analysis').insert(result.toJson());

    return result;
  }

  @override
  Future<AnalysisResult?> getLatestAnalysis({required AnalysisType type}) async {
    // Fetch the latest analysis from the database
    final response =
        await supabase.from('analysis').select().eq('type', type.name).order('timestamp', ascending: false).limit(1).maybeSingle();

    if (response == null) return null;

    return AnalysisResult.fromJson(response);
  }

  List<ChatCompletionMessage> _buildMessages(List<Entry> entries, AnalysisType type) {
    final messages = <ChatCompletionMessage>[
      ChatCompletionMessage.system(
        content: 'You are an AI assistant that analyzes journal entries, and provides insights based on the entries provided. '
            'You will receive a list of journal entries and you will analyze them to provide insights based on the requested information.',
      ),
      ChatCompletionMessage.user(
        content: ChatCompletionUserMessageContent.string('Based on the following entries, provide ${_getMessageForType(type)}'),
      ),
    ];

    for (final entry in entries) {
      messages.add(
        ChatCompletionMessage.user(content: ChatCompletionUserMessageContent.string(_buildEntryContent(entry))),
      );
    }

    return messages;
  }

  String _getMessageForType(AnalysisType type) {
    switch (type) {
      case AnalysisType.pastWeek:
        return 'a summary of emotional trends, key themes, and notable moments from the last week.';
      case AnalysisType.futureInsights:
        return 'potential areas for growth, things to be mindful of, or positive developments to look forward to. '
            'As well as things to be aware of based on past entries.';
      case AnalysisType.suggestions:
        return 'A list of suggestions for improving my journaling practice based on my past entries, '
            'such as prompts, techniques, or topics to explore.';
    }
  }

  String _buildEntryContent(Entry entry) {
    return "Title: ${entry.title}\nContent: ${entry.content}


lib/features/analysis/analysis_provider.dart

enum AnalysisOperationStatus { idle, loading, success, error }

class AnalysisNotifier extends ChangeNotifier {
  final AnalysisService _analysisService = services.get<AnalysisService>();

  final Map<AnalysisType, AnalysisResult?> _analyses = {};
  final Map<AnalysisType, AnalysisOperationStatus> _statuses = {};
  final Map<AnalysisType, String?> _errors = {};

  AnalysisResult? getAnalysis(AnalysisType type) => _analyses[type];
  AnalysisOperationStatus getStatus(AnalysisType type) => _statuses[type] ?? AnalysisOperationStatus.idle;
  String? getError(AnalysisType type) => _errors[type];
  bool get isAnyLoading => _statuses.values.any((status) => status == AnalysisOperationStatus.loading);

  AnalysisNotifier() {
    for (var type in AnalysisType.values) {
      _statuses[type] = AnalysisOperationStatus.idle;
    }
  }

  Future<void> fetchAnalysis(AnalysisType type, {bool forceRefresh = false}) async {
    if (!forceRefresh &&
        (_statuses[type] == AnalysisOperationStatus.loading ||
            (_statuses[type] == AnalysisOperationStatus.success && _analyses[type] != null))) {
      return;
    }

    _statuses[type] = AnalysisOperationStatus.loading;
    _errors[type] = null;
    // Notify listeners early for loading state, if not already loading from a previous call for the same type.
    // This specific check might be redundant if fetchAllAnalysesIfNeeded calls this only for idle/error states.

    try {
      final result =
          (forceRefresh ? await _analysisService.analyzeEntries(type: type) : await _analysisService.getLatestAnalysis(type: type)) ??
              await _analysisService.analyzeEntries(type: type);
      if (result != null && result.content.isNotEmpty) {
        _analyses[type] = result;
        _statuses[type] = AnalysisOperationStatus.success;
      } else {
        _errors[type] = result?.content.isEmpty == true ? 'Received empty analysis content.' : 'No analysis content received.';
        _statuses[type] = AnalysisOperationStatus.error;
        _analyses.remove(type); // Clear previous successful analysis if any
      }
    } catch (e) {
      _errors[type] = e.toString().replaceFirst("Exception: ", "");
      _statuses[type] = AnalysisOperationStatus.error;
      _analyses.remove(type); // Clear previous successful analysis if any
    }
    notifyListeners();
  }

  Future<void> fetchAllAnalysesIfNeeded({bool forceRefresh = false}) async {
    List<Future<void>> futures = [];
    for (var type in AnalysisType.values) {
      if (_statuses[type] == AnalysisOperationStatus.idle || _statuses[type] == AnalysisOperationStatus.error) {
        // Don't await here, let them run concurrently.
        // The fetchAnalysis method itself will notify listeners.
        futures.add(fetchAnalysis(type, forceRefresh: forceRefresh));
      }
    }
    // Although individual fetches notify, a final notifyListeners after all initial calls might be useful
    // if there's a desire to react once all initial fetches are *initiated*.
    // However, since each fetchAnalysis notifies on its own state changes (loading -> success/error),
    // this might not be strictly necessary. For now, individual notifications are primary.
    if (futures.isNotEmpty) {
      // This ensures all fetches are initiated.
      // Await all futures if you need to do something after all attempts have completed,
      // but for UI updates, individual notifications from fetchAnalysis are key.
    }
  }

  Future<void> reanalyzeEntries() async {
    // This method can be used to reanalyze all entries, clearing previous analyses.
    _analyses.clear();
    _statuses.clear();
    _errors.clear();
    for (var type in AnalysisType.values) {
      _statuses[type] = AnalysisOperationStatus.idle;
    }
    notifyListeners();
    await fetchAllAnalysesIfNeeded(forceRefresh: true);
  }
}

class AnalysisProvider extends StatelessWidget {
  const AnalysisProvider({super.key, required this.child});

  final Widget child;

  static AnalysisNotifier of(BuildContext context, {bool listen = true}) => Provider.of<AnalysisNotifier>(context, listen: listen);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AnalysisNotifier>(
      create: (_) => AnalysisNotifier(),
      child: child,
    );
  }
}


🔗 4. Connecting It All

We have reached a stage where everything works now, but it is all running on mock data. Now, let's connect it to the real database and the real services.


Assuming you already have a project on Supabase, we need to initialize the Supabase client using the project's credentials.


The final version of the main file will look like this:

Future<void> main() async {
  initServices();
  await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return AnalysisProvider(
      child: AuthProvider(
        child: EntriesProvider(
          child: MaterialApp.router(
            title: 'AI Journal',
            debugShowCheckedModeBanner: false,
            theme: ThemeData(
              colorScheme: ColorScheme.fromSeed(
                seedColor: Colors.lightBlue,
                brightness: Brightness.dark,
              ),
              brightness: Brightness.dark,
            ),
            routerConfig: router,
          ),
        ),
      ),
    );
  }
}


We placed all constant values, such as the supabaseUrl and the anonKey, in a file so that our code is more readable.


lib/utils/consts.dart

final supabaseUrl = const String.fromEnvironment(
  'SUPABASE_URL',
  defaultValue: 'https://your-project-id.supabase.co', // Replace with your actual Supabase URL or set it in environment variables
);

final supabaseAnonKey = const String.fromEnvironment(
  'SUPABASE_ANON_KEY',
  defaultValue: 'your-anon-key-here', // Replace with your actual Supabase anon key or set it in environment variables
);

final openAIProxyUrl = const String.fromEnvironment(
  'OPENAI_PROXY_URL',
  defaultValue: 'https://your-openai-proxy-url.com/v1', // Replace with your actual OpenAI proxy URL or set it in environment variables
);


We also updated the services file.


lib/utils/services.dart

final GetIt services = GetIt.instance;
final supabase = Supabase.instance.client;

void initServices() {
  services.registerLazySingleton<EntriesSource>(() => EntriesSourceImpl());
  services.registerLazySingleton<AuthService>(() => AuthServiceImpl());
  services.registerLazySingleton<AnalysisService>(() => AnalysisServiceImpl());
}


We updated the router configuration so it would automatically respond to authentication state changes and redirect the user to the correct page.


lib/utils/router.dart

final GoRouter router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final matchedLocation = state.matchedLocation;
    final authService = services.get<AuthService>();
    final isLoggedIn = authService.currentUser != null;
    final loggingIn = matchedLocation == '/login' || matchedLocation == '/register';
    final isProfilePage = matchedLocation == '/profile';
    final queryParams = state.uri.queryParameters;
    final returnUrl = queryParams.containsKey("returnUrl") ? Uri.decodeComponent(queryParams['returnUrl']!) : null;

    // If the user is not logged in and trying to access the profile page, redirect to login
    if (!isLoggedIn && !loggingIn && isProfilePage) return '/login?returnUrl=${Uri.encodeComponent(matchedLocation)}';

    // If the user is logged in and trying to access login or register, redirect to home
    if (isLoggedIn && loggingIn) return returnUrl ?? '/';

    return null; // No redirection needed
  },
  refreshListenable: AuthNotifier(),
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const LayoutScreen(),
      routes: [
        GoRoute(
          path: 'login',
          builder: (context, state) => const LoginScreen(),
        ),
        GoRoute(
          path: 'register',
          builder: (context, state) => const RegisterScreen(),
        ),
        GoRoute(
          path: 'entry/:id',
          builder: (context, state) => EntryDetails(entryId: state.pathParameters['id']!),
        ),
        GoRoute(
          path: 'profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);


Now let's implement the non-mock services from earlier.


lib/features/auth/auth_service.dart

[...]

class AuthServiceImpl implements AuthService {
  AuthServiceImpl() {
    _ensureInitialized();
  }

  SharedPreferences? _sharedPreferences;
  bool get _initialized => _sharedPreferences != null;

  User? _currentUser;
  final StreamController<User?> _authStateController = StreamController<User?>.broadcast();

  @override
  Stream<User?> get authStateChanges => _authStateController.stream;

  @override
  User? get currentUser => _currentUser;

  @override
  Future<User?> signInWithEmailPassword(String email, String password) async {
    await _ensureInitialized();
    final result = await supabase.auth.signInWithPassword(password: password, email: email);
    if (result.user == null) {
      await signOut();
      throw Exception('Invalid email or password');
    }
    _setCurrentUser(User(id: result.user!.id, email: result.user!.email!, token: result.session!.accessToken));
    return _currentUser;
  }

  @override
  Future<void> signOut() async {
    await _ensureInitialized();
    await supabase.auth.signOut();
    _setCurrentUser(null);
  }

  @override
  Future<User?> signUpWithEmailPassword(String email, String password) async {
    await _ensureInitialized();
    final result = await supabase.auth.signUp(email: email, password: password);
    if (result.user == null) {
      throw Exception('Failed to sign up');
    }
    _setCurrentUser(User(id: result.user!.id, email: result.user!.email!, token: result.session!.accessToken));
    return _currentUser;
  }

  @override
  void dispose() {
    _authStateController.close();
  }

  Future<void> _init() async {
    _sharedPreferences ??= await SharedPreferences.getInstance();
    await _tryLoadUser();
  }

  Future<void> _ensureInitialized() async {
    if (!_initialized) {
      await _init();
    }
  }

  Future<void> _tryLoadUser() async {
    try {
      // First, try to load the user from the current session
      var accessToken = supabase.auth.currentSession?.accessToken ?? _sharedPreferences!.getString('user_token');
      UserResponse? authUser;

      // Get user token from SharedPreferences
      if (accessToken != null) {
        authUser = await supabase.auth.getUser(accessToken);
        if (authUser.user != null) {
          _setCurrentUser(User(id: authUser.user!.id, email: authUser.user!.email!, token: accessToken));
        }
      }
    } on AuthApiException catch (e) {
      if (e.code == 'bad_jwt') {
        await _tryRefreshSession();
      } else {
        rethrow;
      }
    } catch (e) {
      // Handle other exceptions, possibly log them
      debugPrint('Error loading user: $e');
      _setCurrentUser(null);
    }
  }

  Future<void> _tryRefreshSession() async {
    var refreshToken = supabase.auth.currentSession?.refreshToken ?? _sharedPreferences!.getString('refresh_token');
    AuthResponse? authUser;

    if (refreshToken != null) {
      authUser = await supabase.auth.refreshSession(refreshToken);
      if (authUser.session != null && authUser.user != null) {
        _setCurrentUser(
          User(id: authUser.user!.id, email: authUser.user!.email!, token: authUser.session!.accessToken),
          authUser.session!.refreshToken,
        );
      } else {
        _setCurrentUser(null);
      }
    }
  }

  void _setCurrentUser(User? user, [String? refreshToken]) {
    _currentUser = user;
    _authStateController.add(_currentUser);
    if (user != null) {
      _sharedPreferences?.setString('user_token', user.token);
      if (refreshToken != null) _sharedPreferences?.setString('refresh_token', refreshToken);
    } else {
      _sharedPreferences?.remove('user_token');
      _sharedPreferences?.remove('refresh_token');
    }
  }
}


lib/features/entries/sources/entry_source.dart

[...]

class EntriesSourceImpl implements EntriesSource {
  @override
  Future<Entry> addEntry({String? title, required String content}) async {
    final result = await supabase.from('journals').insert({
      'title': title,
      'content': content,
    }).select();
    return Entry.fromJson(result.first);
  }

  @override
  Future<void> deleteEntry(String id) => supabase.from('journals').delete().eq('id', id);

  @override
  Future<List<Entry>> getEntries({int? skip, int? limit}) async {
    PostgrestTransformBuilder<PostgrestList> query = supabase.from('journals').select().order('date', ascending: false);
    if (skip != null) {
      query = query.range(skip, skip + (limit ?? 10) - 1);
    }

    var result = await query;

    return result.map((e) => Entry.fromJson(e)).toList();
  }

  @override
  Future<Entry?> getEntry(String id) async {
    final result = await supabase.from('journals').select().eq('id', id).maybeSingle();
    if (result == null) return null;

    return Entry.fromJson(result);
  }

  @override
  Future<void> updateEntry(Entry entry) async {
    await supabase.from('journals').update({
      'title': entry.title,
      'content': entry.content,
    }).eq('id', entry.id)


🔒 5. Securing our communications

One of the most critical aspects of building an app without a backend is security. Many developers skip over this process because they believe that reverse-engineering an app is impossible or too much of a hassle. Still, I recently covered how ridiculously easy it is to uncover the secrets used in the app in this post.


For this reason, we'll use a proxy server that securely injects the keys server-side. Shameless plug, Proxana is tailor-made for this exact use case.

Creating the Proxy

  1. We'll start by creating a proxy, selecting the OpenAI template, as that is the one we're using.

  1. Then I'll give my proxy a name.

  1. And finally, I'll create a new secret and supply the API key I obtained from OpenAI, then click Finish.

  1. I'll copy the proxy's URL

  1. And finally, I can use the proxy's URL instead of OpenAI's URL.

Authenticating the Proxy

Since we're using Supabase Authentication, we can pass the JSON Web Token (JWT) of the current session to the proxy. But first, we need to configure the proxy to validate the JWT and authorize the user.


Before we start, let's log in to Supabase and copy the JWT signing secret. Go to your project's settings, under Data API. You will find the JWT Secret. Click reveal, then copy.

To enable Role Based Access Control (RBAC) in Supabase make sure to follow their guide.

  1. In the proxy's settings in Proxana, under the Security section, enable Identify your users

  1. Change Placement to JWT, and User Claim Key and Group Claim Key to sub and user_role respectively.

  1. Change the Verification Method to Jwt Secret and paste the JWT Secret you copied from Supabase

  1. Finally, click save

Rate-Limit Rules

So far, we have created the proxy and secured it by validating the JWT. Now we need to enforce rate-limit rules, otherwise anyone who can get their hands on your app can run up the bills.


For our purposes, we will define global and anonymous rate limit rules. And a rate limit rule for each group (e.g., Pro, Premium, etc.)

  1. In the proxy's settings in Proxana, under the Rules section, enable the Global, Anonymous, and Group rules. And then define the rules.

The asterisks * group rule is for groups that doesn't match any of the pre-defined rules. Learn more


🚀 7. Recap

What we achieved so far:

  • ✅ Developed a fully functional app

  • ✅ Authenticated users using Supabase Authentication

  • ✅ Stored user's journal entries in Supabase Database

  • ✅ Used OpenAI to analyze entries

  • ✅ Secured communication between our app and OpenAI using Proxana

  • ✅ Defined rate-limit rules to prevent abuse


Gone are the days when developing an app required deploying a full-fledged backend, setting up DevOps and CI/CD pipelines, and configuring firewalls. This article demonstrates that anyone can create and deploy a Flutter app without needing to roll out their backend.

🔗 Quick Links