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




INSTRUCTIONS



Figure 1.1 Task 2 Project Brief



BUILD PROCESS

Firstly, I imported the colour and typography system from my existing Figma prototype into Flutterflow to efficiently recreate the app’s UI and maintain consistency between the original design and the FlutterFlow implementation.


Figure 2.1 Import Figma Theme to Flutterflow.


Figure 2.2 Figma Theme > Colors


Figure 2.3 Figma Theme > Typography


Figure 2.4 Transfer font system in my Figma design to Flutterflow


UI Improvements

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.

Figure 2.5 (Left) Before increasing margin size and
(Right) After
 increasing margin size


Custom Icons

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.


Figure 2.6 Adding icon on a Button widget


Figure 2.7 Adding icon on a Button widget

Figure 2.8 How to Import Custom Icons. Custom Icons | FlutterFlow University



Create Account Implementation (Create)

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.
Figure 2.9 Account creation storyboard


I successfully implemented the Account Creation flow with a 6-digit email verification system after researching the implementation approach and following Claude's guidance throughout the process:

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)

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.)


Figure 2.10 Setting up Firebase


Figure 2.11 Setting up Firebase


Figure 2.12 Pasting code to Firestore security rules



Part 2: EmailJS Setup

6. Create an EmailJS account

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.
  • IMPORTANT!! In the template's settings panel (usually on the right side), set the "To Email" field to {{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.


    Figure 2.13 Add {{to_email}}

  • Then save it, note the Template ID (e.g. template_xyz789).

  • 9. Get your Public Key

    • Dashboard → AccountAPI Keys → copy your Public Key.

    You now have 3 values to keep handy: service_id, template_id, public_key.

    Figure 2.14 EmailJS


    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_KEY with 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 http package 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 ActionsendVerificationCode, pass in the Email TextField's value.
    • Add a second action after it: Navigate To your verification screen, passing email as a page parameter so the next screen can display it and use it for verification.

    Figure 2.15 Create Custom Actions and pasting code into Custom Actions



    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 parameter email and the Pin Code field's value.
    • Add a Conditional Action: if verifyCode returns true
      • 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."

    Figure 2.16 Error text implementation

    Part 5: Resend code + 60s countdown

    14. Add and Configure the Timer Widget

    1. In your FlutterFlow widget tree, locate the row containing your "Didn't get the code? Resend code" text.

    2. Drag a Timer widget from the Components panel and place it right next to your "Resend code" text.

    3. Select the Timer widget and change these settings in its properties panel on the right:

      • Timer Type: Count Down

      • Initial Time (ms): 60000 (60,000 milliseconds equals 1 minutes).

      • Format: Custom ➔ Type mm:ss so it reads cleanly as 01:00 and 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:

    1. Select your Resend code text/button widget.

    2. Open the Action Flow Editor for its On Tap trigger.

    3. At the very top of the action flow, add a Conditional Action.

    4. Set the condition:

      • First Value: Select Widget StateTimer Value (the remaining milliseconds).

      • Condition: Equal to 0 (or Less Than or Equal to 0).

    5. 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):

    1. Action 1: Select Custom Action ➔ choose sendVerificationCode.

      • Pass your email Page Parameter into it just like before.

    2. Action 2: Select Timer ActionsReset Timer (select your Timer widget).

    3. Action 3: Select Timer ActionsStart 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 Value NOT EQUAL TO 0 ➔ 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:

    1. Click on the root VerificationPage background canvas or select it at the very top of your Widget Tree.
    2. Go to the Actions tab on the far-right panel.
    3. Click the On Page Load tab at the top of the action section (instead of On Tap).
    4. Click Add Action and search for Timer Actions.
    5. 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!


    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.

    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

    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

    1. Go to Custom Code → Custom Actions → + Add Action.
    2. Name it isPasswordValid.
    3. Paste this code:
    bool isPasswordValid(String password) {
      return password.length >= 8;
    }
    1. Check Define Arguments — password should show as String, not nullable, not a list.
    2. Toggle Return Value ON, type Boolean.
    3. Save the action.

    Wire it into your flow

    Inside the FALSE branch of your emailExists conditional (where sendVerificationCode and Navigate To currently sit):

    1. Add a new action above sendVerificationCode: Custom Action → isPasswordValid, passing the Password TextField's value.
    2. Set Action Output Variable Name → passwordValid.
    3. Add a Conditional Action right after it:
      • Single Condition: passwordValid == true
      • TRUE branch → nest your existing sendVerificationCode + Navigate To VerificationPage here
      • FALSE branch → Show Snackbar: "Password must be at least 8 characters."

    Figure 2.18 isPasswordValid Custom Action

    Figure 2.19 Action flow of CreateAccoutPage's SendVerifyCode Btn


    Part 7: Error Prevention

    20. Create the Page State Switch

    1. Close the Action Flow Editor.

    2. Click the background of your VerificationPage to select the page.

    3. Open the Page State tab on the far right panel (the 3rd icon that looks like stacked server drawers/database).

    4. Click Add Field:

      • Field Name: codeIsIncorrect

      • Data Type: Boolean

      • Initial Value: False

    21. Add and Hide the Error Text Widget

    1. Drag a new Text widget from your left component panel and drop it directly under your Pin Code field.

    2. 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.

    3. In that Text widget's properties on the right, scroll down to Conditional Visibility and toggle it ON.

    4. 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

    1. Open the Action Flow Editor back up on your Continue button.

    2. Click on Action 5 (under the FALSE branch).

    3. On the right-side panel, search for and select Update Page State.

    4. Choose your codeIsIncorrect variable.

    5. 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.

    1. Select your Pin Code widget on the canvas.

    2. Open its Actions panel on the right menu and look at the On Changed block (this triggers every time they type a number).

    3. Add an Update Page State action here.

    4. 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



    Figure 2.21 Action Flow of VerificationPage's VerityCodeBtn 


    Figure 2.22 The VerificationPage

    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.


    Figure 2.23 Built-in Firebase Login Action (In LoginBtn's action flow)


    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

    1. Navigate to Custom Code → Custom Actions → + Add Action.
    2. Name the action loginWithEmailPassword.
    3. 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 reasons
    return '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.';
    }
    }

    1. Define the following arguments:
      • email (String) – Required
      • password (String) – Required
    2. Enable Return Value and set the return type to String.
    3. Save the custom action.
    Note: Recent versions of Firebase Authentication often return a generic invalid-credential error instead of user-not-found or wrong-password. This security measure helps prevent attackers from determining whether a particular email address is registered. As a result, many applications display the generic message "Incorrect email or password." rather than indicating which field is incorrect. The custom action above supports both older and newer Firebase behaviours.

    Wire the Custom Action to the Login Button

    1. Remove the existing Authentication → Log In action from the Login button.
    2. Add Custom Action → loginWithEmailPassword.
    3. Pass the following parameters:
      • email → Email TextField value
      • password → Password TextField value
    4. Set the Action Output Variable Name to loginResult.
    5. Add a Conditional Action:
      • First Value: loginResult
      • Comparator: Equal To
      • Second Value: success
    6. 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:

      • cbPrivacyConsent

      • cbMarketingConsent

    Setup:

    • Create a Page State variable:

      • Name: showCheckboxError

      • Type: 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 red

    • Else → Default grey


    Figure 2.24 ConsentToVerify page in Flutterflow


    Figure 2.25 Action flow of ConsentToVerify page's ContinueBtn

    2. SignUpName Page

    Widgets:

    • Name TextField

    • Continue button

    Setup:

    Backend Query:

    • Query Type: Document from Reference

    • Collection: users

    • Reference:

      • 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: name

      • Value: 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


    3. SignUpCountry Page

    Widgets:

    • Country Dropdown

    • Back button

    • Continue button

    Setup:

    Backend Query:

    • Query Type: Document from Reference

    • Collection: users

    • Reference:

      • 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: countryName

      • Value: Dropdown value

    • Navigate to SelectDocument

    FALSE branch:

    • Show Snackbar:

      • "Please select your country."


    Figure 2.28 SignUpCountry page in Flutterflow


    Figure 2.29 Action flow of SignUpCountry page's ContinueBtn


    4. SelectDocument (UploadDocument) Page

    Widgets:

    Horizontal ListView containing 6 manually placed document cards:

    1. Passport

    2. MyKad

    3. MyKAS

    4. MyPR

    5. MyTentera

    6. I don't have all

    Each card contains:

    • Radio icon

    • Document label

    • Document image

    Setup:

    Create Page State variable:

    • Name: selectedDocType

    • Type: 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: selectedDocType

      • Value: selectedDocType

    • Navigate to UploadPhoto

    FALSE branch:

    • Show Snackbar:

      • "Please select your document type."

    Figure 2.30 SelectDocument page in Flutterflow


    Figure 2.31 Action flow of SelectDocument page's ContinueBtn



    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:

    1. Upload the image to ImgBB (free image hosting service).
    2. Generate an image URL.
    3. Store only the URL in Firestore.

    The app can then display the image using the saved URL.

    1. Create a Free ImgBB API Key

    1. Go to:

    https://api.imgbb.com/

    1. Create a free account.
    2. Navigate to API Key.
    3. Copy your generated API key.

    You will use this key inside the FlutterFlow Custom Action.

    2. Create a Custom Action: uploadImageGetUrl

    In FlutterFlow:

    1. Go to:
    Custom Code
    → Actions
    → + Add Custom Action
    1. Create a new action:
    Name:
    uploadImageGetUrl
    1. Add an argument:
    SettingValue
    NameuploadedFile
    TypeFFUploadedFile
    NullableEnabled
    1. 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.


    Figure 2.33 Registered free account on imgbb and get 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.

    Figure 2.34 UploadPhoto page in Flutterflow

    Figure 2.35 ActionFlow of UploadPhoto page's ContinueBtn


    Upload File 

    1. Create a free Cloudinary account

    1. Go to https://cloudinary.com/users/register/free
    2. Sign up (free tier: 25GB storage/bandwidth — plenty for resumes)
    3. 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.

    1. In Cloudinary dashboard → click the gear/Settings icon (top right)
    2. Go to Upload tab
    3. Scroll to Upload presets → click Add upload preset
    4. Set:
      • Signing Mode: change from "Signed" to Unsigned
      • Preset name: give it something memorable, e.g. resume_uploads (or let it auto-generate one)
    5. Click Save
    6. 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 1
    • YOUR_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

    Action 3 : Navigate To→ Next Page


    Figure 2.36 UploadResume page in Flutterflow


    Figure 2.37 ActionFlow of UploadResume page's ContinueBtn


    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


    Figure 2.38 ActionFlow of JobApplication step 1 page's ContinueBtn


    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

    Lastly, to verify that every question is answered before saving, I added a conditional action at the very top of action flow of ContinueBtn to check if 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.

    Figure 2.39 ActionFlow of JobApplication step 2 page's ContinueBtn





    CRUD (Create, Read, Update, Delete)

    Create

    Users can create a new account using their email address and password through Firebase Authentication. After registration, a user document is created in Cloud Firestore to store the user's information. Once logged in, Firebase Authentication manages the user's sign-in session, allowing them to remain signed in until they log out.

    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)

    Update 

    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.

    Figure 3.3 Update / replace documents (Update)

    Delete


    Users can delete previously uploaded documents. The selected document's information will be removed or updated in the Firestore collection.

    Figure 3.4 Delete documents (Delete)

    For full walkthrough of CRUD steps in the app, please watch: Application Design II | JobStreet App Redesign – App Concept & Walkthrough



    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