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.
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.
At this stage of the app, our app looks something like this.
📱 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:
Home: This is the home screen that shows us a list of journal entries
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.
Auth: This includes the login and register screens, as well as the service that handles authenticating the user.
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.
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({requiredthis.id,this.title,requiredthis.content,requiredthis.date,});finalStringid;finalString? title;finalStringcontent;finalDateTimedate;/// Factory constructor to create an Entry from a mapfactoryEntry.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 mapMap<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
abstractclass 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;boolget_initialized =>_sharedPreferences != null;
@overrideFuture<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 SharedPreferencesfinal entries = _sharedPreferences!.getStringList('entries') ?? [];entries.add(jsonEncode(entry.toJson()));await_sharedPreferences!.setStringList('entries',entries);returnentry;}
@overrideFuture<void> deleteEntry(String id) async {await_ensureInitialized();// Retrieve the current entriesfinal entries = _sharedPreferences!.getStringList('entries') ?? [];// Filter out the entry with the specified IDfinal updatedEntries = entries.where((entry)=>Entry.fromJson(jsonDecode(entry)).id != id).toList();// Save the updated entries back to SharedPreferencesawait_sharedPreferences!.setStringList('entries',updatedEntries);}
@overrideFuture<List<Entry>> getEntries({int? skip,int? limit})async{await_ensureInitialized();// Retrieve the entries from SharedPreferencesfinal entries = _sharedPreferences!.getStringList('entries') ?? [];// Convert the JSON strings back to Entry objectsreturnentries.map((entry)=>Entry.fromJson(jsonDecode(entry))).toList();}
@overrideFuture<Entry?> getEntry(String id) async {await_ensureInitialized();// Retrieve the entries from SharedPreferencesfinal entries = _sharedPreferences!.getStringList('entries') ?? [];// Find the entry with the specified IDfinal entryJson = entries.firstWhere((entry)=>Entry.fromJson(jsonDecode(entry)).id == id,orElse:()=> '',);// If no entry was found, return nullif(entryJson.isEmpty){returnnull;}// Convert the JSON string back to an Entry objectreturnEntry.fromJson(jsonDecode(entryJson));}
@overrideFuture<void> updateEntry(Entry entry) async {await_ensureInitialized();// Retrieve the current entriesfinal entries = _sharedPreferences!.getStringList('entries') ?? [];// Find the index of the entry to updatefinal index = entries.indexWhere((e)=>Entry.fromJson(jsonDecode(e)).id == entry.id);if(index != -1){// Update the entry at the found indexentries[index] = jsonEncode(entry.toJson());await_sharedPreferences!.setStringList('entries',entries);}}Future<void> _init()async=>_sharedPreferences ??= awaitSharedPreferences.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;voidinitServices(){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.
Create Entry: We will display this screen in the layout screen. It will include two fields, the title field and the story field.
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.
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 modelclass User {finalStringid;finalStringemail;finalStringtoken;User({requiredthis.id,requiredthis.email,requiredthis.token});}// AuthService abstract classabstractclass AuthService {Future<User?> signInWithEmailPassword(String email,String password);Future<User?> signUpWithEmailPassword(String email,String password);Future<void> signOut();User? getcurrentUser;Stream<User?> getauthStateChanges;voiddispose();// For implementations that need cleanup}class MockAuthService implements AuthService {User? _currentUser;finalStreamController<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));}
@overrideUser? get currentUser =>_currentUser;
@overrideStream<User?> getauthStateChanges =>_authStateController.stream;
@overrideFuture<User?> signInWithEmailPassword(String email,String password) async {awaitFuture.delayed(constDuration(seconds:1));// Simulate network delayif(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);}throwException('Invalid email or password');}}
@overrideFuture<User?> signUpWithEmailPassword(String email,String password) async {awaitFuture.delayed(constDuration(seconds:1));// Simulate new user creation_currentUser = User(id: DateTime.now().millisecondsSinceEpoch.toString(),email: email,token:'mock');_authStateController.add(_currentUser);return_currentUser;}
@overrideFuture<void> signOut() async {awaitFuture.delayed(constDuration(seconds:1));if(_currentUser != null){_currentUser = null;_authStateController.add(null);}}
@overridevoiddispose(){_authStateController.close();}}
lib/features/auth/auth_provider.dart
enum AuthOperationStatus {idle,loading,error}class AuthNotifier extendsChangeNotifier{finalAuthService_authService = services.get<AuthService>();StreamSubscription<User?>? _userSubscription;User? _currentUser;User? getcurrentUser =>_currentUser;boolget isAuthenticated =>_currentUser != null;AuthOperationStatus_status = AuthOperationStatus.idle;AuthOperationStatusget status =>_status;String? _errorMessage;String? geterrorMessage =>_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 {awaitoperation();
} 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 extendsStatelessWidget{constAuthScreen({super.key,requiredthis.formWidget,});finalWidgetformWidget;
@overrideWidgetbuild(BuildContext context){final screenHeight = MediaQuery.of(context).size.height;final screenWidth = MediaQuery.of(context).size.width;returnScaffold(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.yamlheight: screenHeight * 0.3,width: screenWidth * 0.7,fit: BoxFit.contain,),),),Text("Welcome to AI Journal",style: Theme.of(context).textTheme.headlineMedium,),formWidget,constSizedBox(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.
class AnalysisScreen extendsStatelessWidget{constAnalysisScreen({super.key});String_getDynamicContentForCard(AnalysisNotifier notifier,AnalysisType type,String cardTitle,String originalPlaceholderContent,){final status = notifier.getStatus(type);final analysis = notifier.getAnalysis(type);switch(status){caseAnalysisOperationStatus.idle:return"Analysis for '$cardTitle' will appear here. Loading...";caseAnalysisOperationStatus.loading:return"Generating analysis for '$cardTitle'...";caseAnalysisOperationStatus.success:returnanalysis?.content ?? "Successfully analyzed '$cardTitle', but content is missing.";caseAnalysisOperationStatus.error:final error = notifier.getError(type);return"Error generating analysis for '$cardTitle': ${error ?? "Unknown error"}";}}
@overrideWidgetbuild(BuildContext context){final authProvider = AuthProvider.of(context);if(authProvider.currentUser == null){returnPadding(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){returnCenter(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 AnalysisProviderfinal analysisNotifier = AnalysisProvider.of(context);analysisNotifier.fetchAllAnalysesIfNeeded();constStringpastWeekTitle = 'Past Week Analysis';constStringfutureInsightsTitle = 'Future Insights & Outlook';constStringsuggestionsTitle = 'Suggested Writing Topics';// Original placeholder contentconstStringpastWeekPlaceholder =
'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.';constStringfutureInsightsPlaceholder =
'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.';constStringsuggestionsPlaceholder =
'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.';returnPadding(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,),),constSizedBox(height:16),_AnalysisCard(title: futureInsightsTitle,icon: Icons.lightbulb_outline_rounded,color: Colors.orange.shade100,content: _getDynamicContentForCard(analysisNotifier,AnalysisType.futureInsights,futureInsightsTitle,futureInsightsPlaceholder,),),constSizedBox(height:16),_AnalysisCard(title: suggestionsTitle,icon: Icons.edit_note_outlined,color: Colors.green.shade100,content: _getDynamicContentForCard(analysisNotifier,AnalysisType.suggestions,suggestionsTitle,suggestionsPlaceholder,),),constSizedBox(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,),),constSizedBox(height:16),FilledButton.icon(onPressed: analysisNotifier.isAnyLoading ? null : ()=>analysisNotifier.reanalyzeEntries(),label:const Text('Reanalyze'),icon:const Icon(Icons.refresh),),],),);}}class _AnalysisCard extendsStatelessWidget{const_AnalysisCard({requiredthis.title,requiredthis.icon,requiredthis.color,requiredthis.content,});finalStringtitle;finalIconDataicon;finalColorcolor;finalStringcontent;
@overrideWidgetbuild(BuildContext context){final ThemeData theme = Theme.of(context);returnHero(tag: title,// Unique tag for the Hero animationchild: 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),constSizedBox(width:12.0),Expanded(child: Text(title,style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold,color: theme.colorScheme.onInverseSurface.withAlpha((0.87 * 255).round()),),),),],),constSizedBox(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 extendsStatelessWidget{finalStringtitle;finalIconDataicon;finalStringfullContent;finalColorcardColor;const_AnalysisDetailScreen({requiredthis.title,requiredthis.icon,requiredthis.fullContent,requiredthis.cardColor,});
@overrideWidgetbuild(BuildContext context){final ThemeData theme = Theme.of(context);returnScaffold(backgroundColor: cardColor.withAlpha((0.05 * 255).round()),body: SafeArea(bottom:false,child: Hero(tag: title,// Same tag as the cardchild: Material(// Material widget is needed for Hero transition of non-Card elements or for consistent appearancetype: MaterialType.card,// Makes it behave like a Card for elevation and shapecolor: cardColor,// Use the cardColor for the Material widgetshape: 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),constSizedBox(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 spacechild: 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,requiredthis.content,requiredthis.type,requiredthis.timestamp,});factoryAnalysisResult.fromJson(Map<String,dynamic> json){returnAnalysisResult(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);
@overrideFuture<AnalysisResult?> analyzeEntries({required AnalysisTypetype,int limit = 10})async{// First we collect the entries based on the date range and limitfinal entries = await_entriesSource.getEntries(skip: 0,limit: limit);if(entries.isEmpty)returnnull;// Call OpenAIfinal 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)returnnull;varresult = AnalysisResult(content: choice.message.content!,type: type,timestamp: DateTime.now(),);// Save the analysis result to the databaseawaitsupabase.from('analysis').insert(result.toJson());returnresult;}
@overrideFuture<AnalysisResult?> getLatestAnalysis({required AnalysisTypetype})async{// Fetch the latest analysis from the databasefinal response =
awaitsupabase.from('analysis').select().eq('type',type.name).order('timestamp',ascending:false).limit(1).maybeSingle();if(response == null)returnnull;returnAnalysisResult.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 extendsChangeNotifier{finalAnalysisService_analysisService = services.get<AnalysisService>();finalMap<AnalysisType, AnalysisResult?> _analyses = {};finalMap<AnalysisType, AnalysisOperationStatus> _statuses = {};finalMap<AnalysisType, String?> _errors = {};AnalysisResult? getAnalysis(AnalysisType type)=>_analyses[type];AnalysisOperationStatusgetStatus(AnalysisType type)=>_statuses[type] ?? AnalysisOperationStatus.idle;String? getError(AnalysisType type)=>_errors[type];boolget isAnyLoading =>_statuses.values.any((status)=>status == AnalysisOperationStatus.loading);AnalysisNotifier(){for(vartypeinAnalysisType.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(vartypeinAnalysisType.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(vartypeinAnalysisType.values){_statuses[type] = AnalysisOperationStatus.idle;}notifyListeners();awaitfetchAllAnalysesIfNeeded(forceRefresh:true);}}class AnalysisProvider extendsStatelessWidget{constAnalysisProvider({super.key,requiredthis.child});finalWidgetchild;staticAnalysisNotifierof(BuildContext context,{bool listen = true})=> Provider.of<AnalysisNotifier>(context,listen: listen);
@overrideWidget build(BuildContext context){returnChangeNotifierProvider<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:
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 = constString.fromEnvironment('SUPABASE_URL',defaultValue:'https://your-project-id.supabase.co',// Replace with your actual Supabase URL or set it in environment variables);final supabaseAnonKey = constString.fromEnvironment('SUPABASE_ANON_KEY',defaultValue:'your-anon-key-here',// Replace with your actual Supabase anon key or set it in environment variables);final openAIProxyUrl = constString.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;voidinitServices(){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 loginif(!isLoggedIn && !loggingIn && isProfilePage)return'/login?returnUrl=${Uri.encodeComponent(matchedLocation)}';// If the user is logged in and trying to access login or register, redirect to homeif(isLoggedIn && loggingIn)returnreturnUrl ?? '/';returnnull;// No redirection needed},
refreshListenable:AuthNotifier(),routes:[GoRoute(path: '/',
builder:(context,state)=>constLayoutScreen(),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;boolget_initialized =>_sharedPreferences != null;User? _currentUser;finalStreamController<User?> _authStateController = StreamController<User?>.broadcast();
@overrideStream<User?> getauthStateChanges =>_authStateController.stream;
@overrideUser? get currentUser =>_currentUser;
@overrideFuture<User?> signInWithEmailPassword(String email,String password) async {await_ensureInitialized();final result = awaitsupabase.auth.signInWithPassword(password: password,email: email);if(result.user == null){awaitsignOut();throwException('Invalid email or password');}_setCurrentUser(User(id: result.user!.id,email: result.user!.email!,token: result.session!.accessToken));return_currentUser;}
@overrideFuture<void> signOut() async {await_ensureInitialized();awaitsupabase.auth.signOut();_setCurrentUser(null);}
@overrideFuture<User?> signUpWithEmailPassword(String email,String password) async {await_ensureInitialized();final result = awaitsupabase.auth.signUp(email: email,password: password);if(result.user == null){throwException('Failed to sign up');}_setCurrentUser(User(id: result.user!.id,email: result.user!.email!,token: result.session!.accessToken));return_currentUser;}
@overridevoiddispose(){_authStateController.close();}Future<void> _init() async {_sharedPreferences ??= awaitSharedPreferences.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 sessionvaraccessToken = supabase.auth.currentSession?.accessToken ?? _sharedPreferences!.getString('user_token');UserResponse? authUser;// Get user token from SharedPreferencesif(accessToken != null){authUser = awaitsupabase.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 themdebugPrint('Error loading user: $e');_setCurrentUser(null);}}Future<void> _tryRefreshSession()async{varrefreshToken = supabase.auth.currentSession?.refreshToken ?? _sharedPreferences!.getString('refresh_token');AuthResponse? authUser;if(refreshToken != null){authUser = awaitsupabase.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');}}}
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
We'll start by creating a proxy, selecting the OpenAI template, as that is the one we're using.
Then I'll give my proxy a name.
And finally, I'll create a new secret and supply the API key I obtained from OpenAI, then click Finish.
I'll copy the proxy's URL
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.
In the proxy's settings in Proxana, under the Security section, enable Identify your users
Change Placement to JWT, and User Claim Key and Group Claim Key to sub and user_role respectively.
Change the Verification Method to Jwt Secret and paste the JWT Secret you copied from Supabase
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.)
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.