Application Design II / Task 2: Hybrid Lo-Fidelity Mobile App Development
22/04/2026 - 19/6/2026 (Week 1 - Week 9)
Chang Wing / 0367807
Application Design II / Bachelors of Design (Honours) in Creative Media / Taylor's University
Task 2: Hybrid Lo-Fidelity Mobile App Development
TABLE OF CONTENTS
Figure 2.1 Import Figma Theme to Flutterflow.
I improved some of the UI issues mentioned in Task 1 - Application Design I Self-Evaluation and Reflection such as adjusting the margin sizes to improve spacing and layout consistency. I also fixed the font sizes that were too small on mobile screens to make the content easier to read for users. Besides that, I made some additional UI refinements to improve the overall usability and UX of the app.
(Right) After increasing margin size
Because FlutterFlow does not support directly uploading SVG or PNG files as custom icons, the assets must first be converted into a TTF (icon font) file. This process is carried out by following the steps outlined in Figure 2.8.
To implement the account creation flow, I designed a rather straightforward email verification process for main flow demonstration purpose (Figure 2.9). Users begin by entering their email address on the Sign Up page. When they tap the registration button, the app generates a unique 6-digit verification code, stores it in Firestore, and sends it to the user's inbox using EmailJS. The user is then redirected to the Verification page to enter the code.
Due to paid feature limitations, a complete authentication architecture using Firebase Cloud Functions and secure session management was not implemented. Instead, the verification logic was handled on the client side for this prototype. Once the verification code is successfully validated, the application creates the user's account using Firebase Authentication.
Part 1: Firebase Setup
1. Create/open your Firebase project
- Go to console.firebase.google.com → create a project (or use the one FlutterFlow already made for you if you enabled Firebase in your project settings).
2. Enable Authentication
- Left menu → Build > Authentication → Get Started.
- Under Sign-in method, enable Email/Password.
3. Enable Firestore
- Left menu → Build > Firestore Database → Create database → start in test mode for now (we'll lock it down in step 5).
4. Create the verification_codes collection
- You don't need to manually create it — it'll auto-create the first time your app writes to it. Just know the shape:
- Collection:
verification_codes - Document ID: the user's email (easiest way to look it up later)
- Fields:
code(string),createdAt(timestamp),attempts(number, default 0)
- Collection:
5. Firestore security rules
Go to Firestore → Rules tab, and set:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /verification_codes/{email} {
allow read, write: if true; // we'll tighten this later once it's working
}
}
}Publish. (This is intentionally loose to get you moving — I'll flag a tighter version at the end.)
Part 2: EmailJS Setup
6. Create an EmailJS account
- Go to emailjs.com → sign up free.
7. Connect Gmail as your email service
- Dashboard → Email Services → Add New Service → choose Gmail → authorize with your Google account.
- Note the Service ID it gives you (e.g.
service_abc123).
8. Create an email template
- Dashboard → Email Templates → Create New Template.
- Subject:
Your verification code - Body, something like:
Hi,
Your verification code is: {{code}}
This code expires in 10 minutes.{{to_email}}. This tells EmailJS which variable in your API call actually contains the recipient's address — without this, EmailJS won't know where to send the email, even if the subject and body look correct.template_xyz789).9. Get your Public Key
- Dashboard → Account → API Keys → copy your Public Key.
You now have 3 values to keep handy: service_id, template_id, public_key.
Part 3: FlutterFlow — Send Code Action
10. Add a Custom Action to generate + store the code
- FlutterFlow → Custom Code > Custom Actions → Add New Action.
- Name it
sendVerificationCode, input parameter:email(String). - Paste this Dart code:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<bool> sendVerificationCode(String email) async {
try {
final code = (100000 + Random().nextInt(900000)).toString();
await FirebaseFirestore.instance
.collection('verification_codes')
.doc(email)
.set({
'code': code,
'createdAt': Timestamp.now(),
'attempts': 0,
});
final response = await http.post(
Uri.parse('https://api.emailjs.com/api/v1.0/email/send'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'service_id': 'YOUR_SERVICE_ID',
'template_id': 'YOUR_TEMPLATE_ID',
'user_id': 'YOUR_PUBLIC_KEY',
'template_params': {
'to_email': email,
'code': code,
},
}),
);
return response.statusCode == 200;
} catch (e) {
return false;
}
}- Replace
YOUR_SERVICE_ID,YOUR_TEMPLATE_ID,YOUR_PUBLIC_KEYwith your actual values from Part 2. - Make sure your email template's "To Email" field is set to
{{to_email}}in EmailJS so it knows where to send it. - FlutterFlow will prompt you to add the
httppackage as a dependency if it's not already there — accept it.
11. Wire it to your "Send verification code via email" button
- On your Create Account page, select the button → Actions → Add Action → Custom Action →
sendVerificationCode, pass in the Email TextField's value. - Add a second action after it: Navigate To your verification screen, passing
emailas a page parameter so the next screen can display it and use it for verification.
Part 4: FlutterFlow — Verify Code Action
12. Add a second Custom Action to check the code
- Name it
verifyCode, inputs:email(String),enteredCode(String). Paste this code:
import 'package:cloud_firestore/cloud_firestore.dart';
Future<bool> verifyCode(String email, String enteredCode) async {
try {
final doc = await FirebaseFirestore.instance
.collection('verification_codes')
.doc(email)
.get();
if (!doc.exists) return false;
final data = doc.data()!;
final storedCode = data['code'] as String;
final createdAt = (data['createdAt'] as Timestamp).toDate();
final attempts = data['attempts'] as int;
if (attempts >= 5) return false; // too many tries
final expired = DateTime.now().difference(createdAt).inMinutes > 1;
if (expired) return false;
if (storedCode != enteredCode) {
await FirebaseFirestore.instance
.collection('verification_codes')
.doc(email)
.update({'attempts': attempts + 1});
return false;
}
// success — clean up
await FirebaseFirestore.instance
.collection('verification_codes')
.doc(email)
.delete();
return true;
} catch (e) {
return false;
}
}13. Wire up your Pin Code / OTP field
- On the verification screen, use FlutterFlow's Pin Code Field widget set to 6 digits.
- On the "Continue" button: Action → Custom Action →
verifyCode, passing the page parameteremailand the Pin Code field's value. - Add a Conditional Action: if
verifyCodereturnstrue→- Action: Firebase Auth > Create Account with Email & Password (you'll need a password — either have the user set one on the signup screen, or generate a random one if you're doing passwordless-style access)
- Then Navigate To your home page.
- If
false→ show an error text/snackbar like "Invalid or expired code."
Part 5: Resend code + 60s countdown
14. Add and Configure the Timer Widget
In your FlutterFlow widget tree, locate the row containing your "Didn't get the code? Resend code" text.
Drag a Timer widget from the Components panel and place it right next to your "Resend code" text.
Select the Timer widget and change these settings in its properties panel on the right:
Timer Type: Count Down
Initial Time (ms): 6
0000(60,000 milliseconds equals 1 minutes).Format: Custom ➔ Type
mm:ssso it reads cleanly as01:00and counts down beautifully (e.g.,00:59,00:58).
15. Disable the "Resend Code" Click Action
To lock the button so users can't spam it while it's counting down:
Select your Resend code text/button widget.
Open the Action Flow Editor for its On Tap trigger.
At the very top of the action flow, add a Conditional Action.
Set the condition:
First Value: Select Widget State ➔ Timer Value (the remaining milliseconds).
Condition: Equal to
0(or Less Than or Equal to0).
Move your resend logic completely into the TRUE branch. Leave the FALSE branch totally blank (this ensures clicking it does nothing until the timer hits
00:00).
16. Wire Up the Resend Logic (The TRUE Branch)
Inside that TRUE branch (when the timer hits 0 and they finally tap it):
Action 1: Select Custom Action ➔ choose
sendVerificationCode.Pass your
emailPage Parameter into it just like before.
Action 2: Select Timer Actions ➔ Reset Timer (select your Timer widget).
Action 3: Select Timer Actions ➔ Start Timer (select your Timer widget to kick off the new 1-minute countdown).
💡 Pro-Tip for 1 Minute: Dynamic Text Color
Because 1 minutes is a long wait, making the text turn grey makes the restriction obvious to the user:
Select the Resend code text widget.
Click the variable icon next to Text Color and choose Conditional Value:
If:
Timer ValueNOT EQUAL TO0➔ Set color to Grey.Else: ➔ Set color to your primary theme color (Pink/Blue).
17. Start it via the "On Page Load" Action
Force the timer to start the exact moment the page opens using an action:
- Click on the root VerificationPage background canvas or select it at the very top of your Widget Tree.
- Go to the Actions tab on the far-right panel.
- Click the On Page Load tab at the top of the action section (instead of On Tap).
- Click Add Action and search for Timer Actions.
- Set the configuration:
Action Type: Start
Select Timer: Choose your Timer widget from the dropdown.
This achieves the exact same result: the moment the user transitions to this page, the 5-minute (300,000 ms) countdown will instantly trigger and start ticking down!
Action Type: Start
Select Timer: Choose your Timer widget from the dropdown.
Part 6: Password Implementation and Error Prevention
18. Added a Create Password field
- Added a Password TextField below the Email field on the Create Account page.
- Enabled Password Field (obscured text).
- Set Keyboard Type to Visible Password.
- Enabled the built-in Show/Hide Password toggle.
19. Passed the password to the Verification page
- Added a new Page Parameter on VerificationPage:
- password (String, Required)
- Updated the SendVerifyCodeBtn navigation action to pass the password from the Password TextField to VerificationPage, alongside the existing email parameter.
- password (String, Required)
20. Create a new Custom Action: checkEmailExists
Go to Custom Code → Custom Actions → + Add Action, name it checkEmailExists, and use this:
import 'package:cloud_firestore/cloud_firestore.dart';
Future<bool> checkEmailExists(String email) async {
try {
final result = await FirebaseFirestore.instance
.collection('users')
.where('email', isEqualTo: email)
.limit(1)
.get();
return result.docs.isNotEmpty;
} catch (e) {
return false;
}
}
- Argument:
email (String) - Return Value: ON, type Boolean
import 'package:cloud_firestore/cloud_firestore.dart';
Future<bool> checkEmailExists(String email) async {
try {
final result = await FirebaseFirestore.instance
.collection('users')
.where('email', isEqualTo: email)
.limit(1)
.get();
return result.docs.isNotEmpty;
} catch (e) {
return false;
}
}email (String)This queries your users collection (the one you just fixed to actually store email) and returns true if any document already has that email.
Wire it into the SendVerifyCode Btn
1. Open SendVerifyCodeBtn's Action Flow Editor.
2. Add a new first action,
- before sendVerificationCode:
Custom Action → checkEmailExists, passing the Email TextField's value.
- Action Output Variable Name: emailExists
3. Add a Conditional Action right after:
- If emailExists == true → Show Snackbar: "An account with this email already exists. Please log in instead." (and stop — don't send code or navigate)
- If emailExists == false → proceed with your existing actions (sendVerificationCode, then Navigate To VerificationPage) — nest those two actions inside this FALSE branch.
Figure 2.17 checkEmailExists Custom Action
21. Create isPasswordValid
- Go to Custom Code → Custom Actions → + Add Action.
- Name it
isPasswordValid.
- Paste this code:
bool isPasswordValid(String password) {
return password.length >= 8;
}
- Check Define Arguments —
password should show as String, not nullable, not a list.
- Toggle Return Value ON, type Boolean.
- Save the action.
isPasswordValid.bool isPasswordValid(String password) {
return password.length >= 8;
}password should show as String, not nullable, not a list.Wire it into your flow
Inside the FALSE branch of your emailExists conditional (where sendVerificationCode and Navigate To currently sit):
- Add a new action above
sendVerificationCode: Custom Action →isPasswordValid, passing the Password TextField's value. - Set Action Output Variable Name →
passwordValid. - Add a Conditional Action right after it:
- Single Condition:
passwordValid == true - TRUE branch → nest your existing
sendVerificationCode+Navigate To VerificationPagehere - FALSE branch → Show Snackbar:
"Password must be at least 8 characters."
- Single Condition:
Figure 2.19 Action flow of CreateAccoutPage's SendVerifyCode Btn
Part 7: Error Prevention
20. Create the Page State Switch
Close the Action Flow Editor.
Click the background of your VerificationPage to select the page.
Open the Page State tab on the far right panel (the 3rd icon that looks like stacked server drawers/database).
Click Add Field:
Field Name: codeIsIncorrect
Data Type: Boolean
Initial Value: False
Close the Action Flow Editor.
Click the background of your VerificationPage to select the page.
Open the Page State tab on the far right panel (the 3rd icon that looks like stacked server drawers/database).
Click Add Field:
Field Name:
codeIsIncorrectData Type:
BooleanInitial Value:
False
21. Add and Hide the Error Text Widget
Drag a new Text widget from your left component panel and drop it directly under your Pin Code field.
Style it: Type "Incorrect code. Please try again.", change the font color to Red, and adjust the size so it looks like a clean error label.
In that Text widget's properties on the right, scroll down to Conditional Visibility and toggle it ON.
Click the variable icon next to it, go to Page State, and select codeIsIncorrect. (The text will vanish from your canvas layout preview immediately—don't worry, it's just waiting for the variable to turn true!)
Drag a new Text widget from your left component panel and drop it directly under your Pin Code field.
Style it: Type "Incorrect code. Please try again.", change the font color to Red, and adjust the size so it looks like a clean error label.
In that Text widget's properties on the right, scroll down to Conditional Visibility and toggle it ON.
Click the variable icon next to it, go to Page State, and select
codeIsIncorrect. (The text will vanish from your canvas layout preview immediately—don't worry, it's just waiting for the variable to turn true!)
22. Turn it ON when the code fails
Open the Action Flow Editor back up on your Continue button.
Click on Action 5 (under the FALSE branch).
On the right-side panel, search for and select Update Page State.
Choose your codeIsIncorrect variable.
Set the Update Type to Set Value, and toggle the value to True.
Open the Action Flow Editor back up on your Continue button.
Click on Action 5 (under the
FALSEbranch).On the right-side panel, search for and select Update Page State.
Choose your
codeIsIncorrectvariable.Set the Update Type to Set Value, and toggle the value to True.
23. Turn it OFF when they try typing again
To make the app feel professional, you don't want the red text stuck there forever once they start fixing their mistake.
Select your Pin Code widget on the canvas.
Open its Actions panel on the right menu and look at the On Changed block (this triggers every time they type a number).
Add an Update Page State action here.
Select codeIsIncorrect and set the value back to False.
Now, when they click continue with an incorrect code, your custom text layer instantly displays right beneath the Pin Code boxes. The millisecond they tap to type a new number, it clears out seamlessly!
Figure 2.20 Verification code input error prevention
To make the app feel professional, you don't want the red text stuck there forever once they start fixing their mistake.
Select your Pin Code widget on the canvas.
Open its Actions panel on the right menu and look at the On Changed block (this triggers every time they type a number).
Add an Update Page State action here.
Select
codeIsIncorrectand set the value back to False.
Now, when they click continue with an incorrect code, your custom text layer instantly displays right beneath the Pin Code boxes. The millisecond they tap to type a new number, it clears out seamlessly!
Part 8: Test It
24. Run in Test Mode
- Use FlutterFlow's Run Mode or Test on a real device.
- Enter your own email, confirm you get the email from EmailJS, enter the code, confirm it lets you through.
- Try an expired/wrong code to make sure the failure path shows properly.
Tighten security once it works
Go back to Firestore rules and replace the loose rule with:
match /verification_codes/{email} {
allow read: if request.auth == null; // needed pre-auth
allow write: if true; // still needed for pre-auth writes — see note below
}Because this whole flow runs before the user is authenticated, Firestore rules alone can't fully lock this down client-side — that's the real limitation of the free/no-Cloud-Functions approach. If this app is just for you, testing, or a small closed user base, it's a fine trade-off. If it's going to production with real user data, it's worth eventually moving sendVerificationCode and verifyCode into actual Cloud Functions so the code/comparison logic never touches the client at all.
Log In
FlutterFlow's built-in Firebase Authentication → Log In action automatically handles user authentication and displays default Firebase error messages. However, it does not provide full control over the snackbar content, making it difficult to display customised, user-friendly messages that match the application's UX.
To provide clearer and more consistent feedback, a custom authentication action can be created that returns tailored messages for different login outcomes.
Create a Custom Action: loginWithEmailPassword
- Navigate to Custom Code → Custom Actions → + Add Action.
-
Name the action
loginWithEmailPassword. - Paste the following code:
import 'package:firebase_auth/firebase_auth.dart';Future<String> loginWithEmailPassword(String email, String password) async {try {await FirebaseAuth.instance.signInWithEmailAndPassword(email: email,password: password,);return 'success';} on FirebaseAuthException catch (e) {if (e.code == 'user-not-found') {return 'Incorrect email or password.';} else if (e.code == 'wrong-password') {return 'Incorrect email or password.';} else if (e.code == 'invalid-email') {return 'Incorrect email or password.';} else if (e.code == 'invalid-credential') {// Newer Firebase versions often merge wrong-password and user-not-found into this one code for security reasonsreturn 'Incorrect email or password.';} else if (e.code == 'too-many-requests') {return 'Too many attempts. Please try again later.';} else {return 'Login failed. Please try again.';}} catch (e) {return 'Something went wrong. Please try again.';}}
-
Define the following arguments:
-
email (
String) – Required -
password (
String) – Required
-
email (
- Enable Return Value and set the return type to String.
- Save the custom action.
Wire the Custom Action to the Login Button
- Remove the existing Authentication → Log In action from the Login button.
-
Add Custom Action →
loginWithEmailPassword. -
Pass the following parameters:
- email → Email TextField value
- password → Password TextField value
-
Set the Action Output Variable Name to
loginResult. -
Add a Conditional Action:
-
First Value:
loginResult - Comparator: Equal To
-
Second Value:
success
-
First Value:
-
Configure the outcomes:
- TRUE → Navigate to the Home page.
-
FALSE → Display a Snackbar with its message set to Action Output →
loginResult. This allows the application to dynamically display the customised message returned by the custom action.
Multi-step registration flow
1. ConsentToVerify Page
Widgets:
Two checkboxes:
cbPrivacyConsentcbMarketingConsent
Setup:
Create a Page State variable:
Name:
showCheckboxErrorType: Boolean
Initial value:
false
Validation Logic (ContinueBtn → On Tap):
Add a Conditional Action:
Condition:
cbPrivacyConsent.value == true AND cbMarketingConsent.value == true
TRUE branch:
Update Page State:
showCheckboxError = false
Navigate to
SignUpName
FALSE branch:
Update Page State:
showCheckboxError = true
Trigger shake animation on the checkbox container
Show Snackbar:
"Please agree to both to continue"
Error State Styling:
Bind the checkbox container border/text color conditionally:
If
showCheckboxError == true→ Error redElse → Default grey

Figure 2.24 ConsentToVerify page in Flutterflow
2. SignUpName Page
Widgets:
Name TextField
Continue button
Setup:
Backend Query:
Query Type: Document from Reference
Collection:
usersReference:
Authenticated User → User Reference
This allows the page to retrieve the current user's existing data.
TextField Initial Value:
Bind to:
usersDocument.name
This allows the previously entered name to be automatically restored when the user navigates back from the next page.
Validation Logic (ContinueBtn → On Tap):
Conditional Action:
Condition:
Name TextField value is set / not empty
TRUE branch:
Update Document:
Field:
nameValue: Name TextField value
Navigate to
SignUpCountry
FALSE branch:
Show Snackbar:
"Please enter your name."

Figure 2.26 SignUpName page in Flutterflow
Figure 2.27 Action flow of SignUpName page's ContinueBtn

Figure 2.26 SignUpName page in Flutterflow
3. SignUpCountry Page
Widgets:
Country Dropdown
Back button
Continue button
Setup:
Backend Query:
Query Type: Document from Reference
Collection:
usersReference:
Authenticated User → User Reference
Dropdown Initial Value:
Bind to:
usersDocument.countryName
This keeps the previously selected country when returning to this page.
Validation Logic (ContinueBtn → On Tap):
Conditional Action:
Condition:
Dropdown value is set / not empty
TRUE branch:
Update Document:
Field:
countryNameValue: Dropdown value
Navigate to
SelectDocument
FALSE branch:
Show Snackbar:
"Please select your country."
4. SelectDocument (UploadDocument) Page
Widgets:
Horizontal ListView containing 6 manually placed document cards:
Passport
MyKad
MyKAS
MyPR
MyTentera
I don't have all
Each card contains:
Radio icon
Document label
Document image
Setup:
Create Page State variable:
Name:
selectedDocTypeType: String
Initial value set to 0
Card Selection Logic:
Each document card:
On Tap:
Update Page State:
Example:
selectedDocType = "Passport"
Other values:
"MyKad"
"MyKas"
"MyPR"
"MyTentera"
"none"Radio Icon State:
Use Conditional Icon:
If:
selectedDocType == currentCardValue (exp:"MyKad")
Show:
Filled pink radio icon
Else:
Outline grey radio icon
Validation / Branching Logic (ContinueBtn → On Tap):
Conditional Action:
Condition:
selectedDocType is set
TRUE branch:
Update Document:
User Record Reference
Field:
selectedDocTypeValue:
selectedDocType
Navigate to
UploadPhoto
FALSE branch:
Show Snackbar:
"Please select your document type."

Shared Patterns Used Across All Pages
1. Firestore as the Source of Truth
Each page saves data immediately when the user presses Continue (save-as-you-go approach).
Benefits:
Prevents data loss when navigating backwards.
Data remains available even if the app is closed and reopened.
2. Page Backend Query for Existing User Data
Each page retrieves the current user's document using:
Authenticated User → User Reference
The fetched document is then used to:
Prefill existing values.
Restore previous inputs when users navigate back.
3. Standard Validation Pattern
All pages follow the same validation structure:
User presses Continue
↓
Check if input is valid
↓
IF invalid:
Show Snackbar
(Optional: update error state + show red styling)
↓
IF valid:
Save data to Firestore
Navigate to next page
This keeps validation behaviour consistent throughout the onboarding flow.
Upload Image
I first followed the YouTube video below to implement the image upload feature. The tutorial shows how to upload an image, store it in a Widget State variable, and display the uploaded image on the screen.
I followed the steps until 8:37s, then stopped because the remaining part of the tutorial was not needed for my current implementation.
Since uploading images directly to Firebase Storage requires enabling billing after the free quota, we will use a workaround for the prototype.
Instead of storing the actual image file in Firebase:
- Upload the image to ImgBB (free image hosting service).
- Generate an image URL.
- Store only the URL in Firestore.
The app can then display the image using the saved URL.
1. Create a Free ImgBB API Key
- Go to:
- Create a free account.
- Navigate to API Key.
- Copy your generated API key.
You will use this key inside the FlutterFlow Custom Action.
2. Create a Custom Action: uploadImageGetUrl
In FlutterFlow:
- Go to:
Custom Code → Actions → + Add Custom Action
- Create a new action:
Name: uploadImageGetUrl
- Add an argument:
| Setting | Value |
|---|---|
| Name | uploadedFile |
| Type | FFUploadedFile |
| Nullable | Enabled |
- Set the return type:
Return Type: String
The action will return the uploaded image URL.
Example output:
https://i.ibb.co/example/document-front.jpg
3. Add Custom Action Code
Paste the following code:
// Automatic FlutterFlow imports
import '/backend/schema/structs/index.dart';
import '/backend/schema/enums/enums.dart';
import '/actions/actions.dart' as action_blocks;
import '/flutter_flow/custom_functions.dart';
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<String> uploadImageGetUrl(FFUploadedFile uploadedFile) async {
final bytes = uploadedFile.bytes;
if (bytes == null) {
return '';
}
const apiKey = 'YOUR_IMGBB_API_KEY'; // paste your key here
final base64Image = base64Encode(bytes);
final response = await http.post(
Uri.parse('https://api.imgbb.com/1/upload?key=$apiKey'),
body: {'image': base64Image},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['data']['url'] as String;
} else {
return '';
}
}
Replace:
YOUR_IMGBB_API_KEY
with your actual ImgBB API key.
4. Configure Custom Action Settings
In the right panel:
Return Value
Enable:
Return Value: ON Type: String
Arguments
Configure:
Name: uploadedFile Type: FFUploadedFile Nullable: Enabled
Then:
Save Action
5. Add Upload Action to Continue Button
Select:
Continue Button → Actions
Add a conditional action.
Condition:
Widget State(UploadedLocalFile) is set >
TRUE Branch (If Image is Uploaded):
Action 1 — Upload Image to ImgBB
Add:
Custom Action → uploadImageGetUrl
Argument:
uploadedFile = Widget State(UploadedLocalFile)
Set Action Output Variable: photoUrl
Action 2 — Save URL to Firebase
Add:
Firebase → Update Document
Document:
Current User Reference
Update field:
documentPhotoUrl
Value:
From Variable → Action Outputs: photoUrl
Action 3 — Navigate
After successful upload:
Navigate To → Next Page
FALSE Branch (If No Image is Uploaded)
Show: Snackbar
Message: Please upload a photo of your selected document.
Upload File
1. Create a free Cloudinary account
- Go to https://cloudinary.com/users/register/free
- Sign up (free tier: 25GB storage/bandwidth — plenty for resumes)
- Once logged in, you'll land on your Dashboard — note your Cloud Name shown at the top (e.g.
dxyzabc123)
2. Create an Unsigned Upload Preset
This lets your app upload directly without exposing a secret API key.
- In Cloudinary dashboard → click the gear/Settings icon (top right)
- Go to Upload tab
- Scroll to Upload presets → click Add upload preset
- Set:
- Signing Mode: change from "Signed" to Unsigned
- Preset name: give it something memorable, e.g.
resume_uploads(or let it auto-generate one)
- Click Save
- Copy that exact preset name — you'll need it in the code
3. Update your Custom Action code
Go back into FlutterFlow → Custom Actions → uploadResumeGetUrl → paste the code:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<String> uploadResumeGetUrl(FFUploadedFile uploadedFile) async {
try {
final bytes = uploadedFile.bytes;
if (bytes == null) return 'ERROR: no bytes';
const cloudName = 'YOUR_CLOUD_NAME'; // paste your Cloud Name here
const uploadPreset = 'YOUR_UPLOAD_PRESET'; // paste your preset name here
final uri = Uri.parse('https://api.cloudinary.com/v1_1/$cloudName/raw/upload');
final request = http.MultipartRequest('POST', uri)
..fields['upload_preset'] = uploadPreset
..files.add(
http.MultipartFile.fromBytes(
'file',
bytes,
filename: uploadedFile.name ?? 'resume.pdf',
),
);
final response = await request.send();
final responseBody = await response.stream.bytesToString();
if (response.statusCode == 200) {
final data = jsonDecode(responseBody);
return data['secure_url'] as String;
} else {
return 'STATUS:${response.statusCode} BODY:$responseBody';
}
} catch (e) {
return 'EXCEPTION: $e';
}
}Replace:
YOUR_CLOUD_NAME→ your actual Cloud Name from Step 1YOUR_UPLOAD_PRESET→ your preset name from Step 2
4. Configure Action Settings (right panel)
- Return Value: ON, Type
String - Arguments: Name =
uploadedFile, Type =UploadedFile - Save Action
5. Use it on ContinueBtn (UploadResume page)
Action 1: Add custom action → uploadResumeGetUrl(uploadedFile = captured file) → Set the Action Output Variable Name: resumeURLActionOutput
(Note: DO NOT name the Action Output variable resumeURL, as it WILL conflict with the Firestore field of the SAME NAME and unable to fill in the resumeURL field in Firebase User Ref!!)
Action 2: Add: Update Document > Document: > Firebase User Ref > Update field: resumeURL >Value: From Variable → resumeURLActionOutput
Job Application Steps (Step 1: Upload Resume & Cover Letter)
Step 1: Submit & Error Prevention for Continue Btn
Because users might press back and press continue again, I set up an upsert logic so users can edit their answers without generating duplicate documents.
First, I created a Page State variable (existingAppRef) to store the application’s document reference. On page load, the app runs a single-document query to check if the current user already has an application for this job. If it finds one, it saves that document reference to existingAppRef.When the user taps Continue, the action flow checks if existingAppRef is set:
If TRUE (Draft exists): It updates the existing document with the new uploaded Resume/Cover Letter.If FALSE (First submit): It creates a new jobApplications document, saves all fields, and updates existingAppRef with the new document reference so any future taps edit that same document.
The detailed steps are as below:
Step 1: Create a Page State for the Application Reference
Purpose: Create a place on the page to hold the application document reference once found or created.
Instructions:
Open the Page State panel on the current page.
Click Add Field.
Field Name: existingAppRef
Data Type: Doc Reference pointing to the jobApplications collection.
Leave Is List unchecked and leave default value empty.
Step 2: Query on Page Load
Purpose: Check if an application already exists for this job and user when entering the page.
Instructions:
Select the top-level Page or Scaffold widget from the widget tree.
Open the Actions tab on the right panel and select On Page Load.
Add Action: Firestore Query -> Single Document or Query Collection.
Collection: jobApplications
Query Type: Single Document
Filter 1: jobPostRef Equal To currentJobPostRef
Filter 2: applicantRef Equal To Authenticated User -> User Reference
Name the Action Output Variable to userApplicationDoc.
Add a second action right below the query: Update Page State.
Variable: existingAppRef
Update Type: Set Value
Value: Action Output -> userApplicationDoc -> Reference
Note: If no application exists, existingAppRef will remain null.
Step 3: Configure the ContinueBtn Logic
Purpose: Update the ContinueBtn action flow to handle both creating and updating records.
Instructions:
Select ContinueBtn and open Actions -> On Tap.
Add Conditional Action:
First Value: Page State -> existingAppRef
Condition: Is Set and Not Empty
TRUE Branch (Document Already Exists):
Add Action: Backend Call -> Update Document
Select existingAppRef
Update Field answers: Set Value -> From variable -> resumeURLstep1TRUE action output
Repeat this step for CoverLetterURLstep1TRUE action output
FALSE Branch (First Time Submitting):
Add Action: Backend Call -> Create Document (jobApplications)
Set fields: jobPostRef, applicantRef, uploadedResumeURL, uploadeCoverLetterURL
Set Action Output Name to newApp
Add follow-up action under FALSE: Update Page State -> set existingAppRef = Action Output -> newApp -> Reference
Main Action Chain (After Condition):
Add Page View action beneath the condition block so the user advances regardless of whether it created or updated
First, I created a Page State variable (existingAppRef) to store the application’s document reference. On page load, the app runs a single-document query to check if the current user already has an application for this job. If it finds one, it saves that document reference to existingAppRef.
Step 1: Create a Page State for the Application Reference
Purpose: Create a place on the page to hold the application document reference once found or created.
Instructions:
Open the Page State panel on the current page.
Click Add Field.
Field Name: existingAppRef
Data Type: Doc Reference pointing to the jobApplications collection.
Leave Is List unchecked and leave default value empty.
Step 2: Query on Page Load
Purpose: Check if an application already exists for this job and user when entering the page.
Instructions:
Select the top-level Page or Scaffold widget from the widget tree.
Open the Actions tab on the right panel and select On Page Load.
Add Action: Firestore Query -> Single Document or Query Collection.
Collection: jobApplications
Query Type: Single Document
Filter 1: jobPostRef Equal To currentJobPostRef
Filter 2: applicantRef Equal To Authenticated User -> User Reference
Name the Action Output Variable to userApplicationDoc.
Add a second action right below the query: Update Page State.
Variable: existingAppRef
Update Type: Set Value
Value: Action Output -> userApplicationDoc -> Reference
Note: If no application exists, existingAppRef will remain null.
Step 3: Configure the ContinueBtn Logic
Purpose: Update the ContinueBtn action flow to handle both creating and updating records.
Instructions:
Select ContinueBtn and open Actions -> On Tap.
Add Conditional Action:
First Value: Page State -> existingAppRef
Condition: Is Set and Not Empty
TRUE Branch (Document Already Exists):
Add Action: Backend Call -> Update Document
Select existingAppRef
Update Field answers: Set Value -> From variable -> resumeURLstep1TRUE action output
Repeat this step for CoverLetterURLstep1TRUE action output
FALSE Branch (First Time Submitting):
Add Action: Backend Call -> Create Document (jobApplications)
Set fields: jobPostRef, applicantRef, uploadedResumeURL, uploadeCoverLetterURL
Set Action Output Name to newApp
Add follow-up action under FALSE: Update Page State -> set existingAppRef = Action Output -> newApp -> Reference
Main Action Chain (After Condition):
Add Page View action beneath the condition block so the user advances regardless of whether it created or updated
Job Application Steps (Step 2: Answering employer questions)
Step 1: Database & Data Structure Setup
1. Data Type (userAnswer)
I went to Data Types in the left sidebar > Created a new Data Type: userAnswer.
Added Fields:
-question (String)
-selectedOption (String)
2. Page State Variable
I opened my page's Page State panel.
Added Variable: answersList
Type: Data Type (userAnswer)
Checked Is List: ✅ Yes
3. Collection Schema (jobApplications)
Created/Verified collection: jobApplications
Fields:
jobPostRef (Doc Reference > jobposts)
applicantRef (Doc Reference > users)
answers (List of Strings)
appliedAt (DateTime)
Step 2: Radio Button Selection Logic (On Tap Action)
I selected my option row inside the inner dynamic loop > Actions > On Tap.
Action 1: Remove Existing Answer for This Question
I selected Update Page State >Selected answersList.
Update Type: Set Value > Variable: answersList > Filter List Items.
Filter Condition:
First Value: Item in List > question
Relation: Not Equal To (!=)
Second Value: employerQuestionsChildren item > questionText
Confirmed back to the Action Flow Editor.
Action 2: Add New Answer
I clicked the + (plus icon) directly under Action 1.
Selected Update Page State > Selected answersList.
Update Type: Add to List.
Value to Add: Chose Data Type (userAnswer):
question: employerQuestionsChildren item > questionText
selectedOption: optionsChildren item
Step 3: Visual Highlight Logic (Icon / Color)
I selected my Icon (or Icon Color) widget inside the dynamic option row > Set from Variable > Conditional Value.
Step 1: Open IF Condition
I clicked UNSET next to IF.
Clicked UNSET on First Value.
Step 2: Filter answersList
Selected Page State >answersList.
Available Options: Filter List Items.
Under Filter Condition:
First Value: Item in List > question
Relation: Equal To (==)
Second Value: employerQuestionsChildren item > questionText
Clicked Confirm.
Available Options: Item at Index > Index Type: First.
Selected Field: selectedOption.
Clicked Confirm.
Step 3: Complete Relation
Relation Operator: Equal To (==).
Clicked UNSET on Second Value > Selected optionsChildren item.
Clicked Confirm.
Step 4: Output Selection
THEN (Selected State): Chose my active icon (radio_button_checked) or active color.
ELSE (Unselected State): Chose my default icon (radio_button_unchecked) or inactive color.
Clicked Confirm.
Step 4: Configure the ContinueBtn Logic
Update Document
Add Action: Backend Call -> Update Document
Select existingAppRef
Update Field answers: Set Value -> Page State -> answersList -> Map List Items -> selectedOption
Show next page
Add Page View action beneath the condition block so the user advances to next step
answersList item count equals the total number of questions; if TRUE, execute the existing upsert logic, and if FALSE, show a SnackBar alert "Please answer all questions before continuing." and stop the flow so the user stays on the page.CRUD (Create, Read, Update, Delete)
Figure 3.1 Account Creation Flow (Create)
Read
Users can view and review the documents they uploaded during the job application process, with the document information retrieved from Cloud Firestore while the uploaded files are displayed using their URL links generated through the Cloudinary or ImgBB API.
Figure 3.2 Review documents submitted (Read)
Users can view and review the documents they uploaded during the job application process, with the document information retrieved from Cloud Firestore while the uploaded files are displayed using their URL links generated through the Cloudinary or ImgBB API.
Figure 3.2 Review documents submitted (Read)
After reviewing their uploaded documents, users can edit or replace them with newer versions. The updated information is saved to the existing Firestore document, while the previous file in Firebase Storage is replaced, no duplicate document will be created in the collection.
Delete
Users can delete previously uploaded documents. The selected document's information will be removed or updated in the Firestore collection.
REFLECTION
Starting with FlutterFlow was quite challenging. At first, even building the UI by adding widgets, organising rows and columns, and recreating my Figma design felt difficult because I had very little knowledge of how FlutterFlow works. To overcome this, I used AI tools such as Claude and ChatGPT to guide me by sharing screenshots of my Figma design, I was able to receive step-by-step guidance on how to rebuild my UI in FlutterFlow while also understanding the purpose of different widgets and settings.
After getting more familiar with FlutterFlow, I started implementing the CRUD functionalities. This was the most challenging part because it involved a lot of application logic, conditional actions, backend queries, and passing data between pages. I encountered many errors during development and spent a lot of time troubleshooting them. There were times when even AI could no longer solve the issues because it lost track of the changes I had made in FlutterFlow. The longest issue took me around 7 hours just to fix a single display problem. Although it was frustrating, it forced me to keep experimenting and debugging until I found the cause. Through lots of trial and error, I eventually managed to get all the required logic working.
Besides that, I also learned how to integrate APIs, such as using a country API to populate the country selection field automatically instead of manually adding the options. I learned how to use Cloudinary and ImgBB to store uploaded files externally and retrieve them using URL links. I also explored Custom Actions to implement functions that were not available in FlutterFlow by default.
Overall, this task gave me a much better understanding of how UI/UX design connects with app development. Although I relied a lot on AI tools at the beginning, I gradually became more confident using FlutterFlow and was able to solve some of the problems on my own.


































Comments
Post a Comment