Lab 05: Building a Complete Flutter App — Architecture to Logic
Overview
This lab guides you through the full process of building a complete Flutter application from scratch. You will learn how to plan requirements before writing code, structure your app using a clean three-layer architecture, manage colors with centralized theming, solve scrolling and screen-size issues, design data model classes, and build a logic layer that calculates real values and uses external packages.
Objectives
By the end of this lab you will be able to:
- Define app requirements before writing any code
- Separate UI, Business Logic, and Data into distinct layers
- Apply the DRY principle to write maintainable Flutter code
- Handle UI constraints and user interaction events
- Implement centralized theming using
Theme.of(context)andThemeData - Fix layout overflow errors caused by varying device screen sizes
- Create Dart model classes to represent structured data
- Build a logic-layer widget with strict data type enforcement
- Search and integrate packages from the Flutter Pub repository
Prerequisites
- Familiarity with Flutter widgets (
Column,Row,Text,Container) - Basic Dart class syntax
- A running Flutter development environment
Background
The Three-Layer Architecture
Before writing a single line of code, every professional Flutter app starts with a clear separation of responsibilities. This is the most fundamental principle in building maintainable apps.
graph TD
subgraph UI["UI Layer"]
A[Widgets and Screens]
B[User Interactions]
C[Visual Calculations]
end
subgraph Logic["Business Logic Layer"]
D[Date Processing]
E[Search Logic]
F[Day Calculations]
end
subgraph DataLayer["Data Layer"]
G[Models and Classes]
H[External Data]
end
A --> D
B --> D
D --> G
E --> G
F --> GWhy this matters: When you hardcode logic directly in the UI, every update forces you to change multiple places at once. A well-structured app lets you change things in exactly one place. If you find yourself making the same change in multiple locations, this is a clear sign that your code is poorly organized.
Planning: Defining Requirements
The very first step in building any application is not opening the code editor — it is defining exactly what the application needs to do. This is called defining the program requirements.
What Are Requirements?
Requirements specify:
- What actions the user should be able to take
- What visual elements the app must display
- What data the app needs to access and process
The Three Layers in Detail
Once requirements are defined, you organize your app into three isolated layers:
| Layer | Responsibility | Examples |
|---|---|---|
| UI | Visual elements, user input, screen layout | Widgets, buttons, text |
| Business Logic | Calculations, processing, rules | Date calculations, search filtering |
| Data | Storing and supplying raw information | Model classes, data sources |
The critical rule is: the UI layer never directly touches the data layer. The Business Logic layer acts as the bridge.
flowchart LR
User([User Action]) --> UI[UI Layer]
UI -->|Request| Logic[Business Logic]
Logic -->|Query| Data[Data Layer]
Data -->|Return| Logic
Logic -->|Result| UI
UI -->|Display| UserTask 1 — Write a Requirements Document
Before starting any code this session, write a requirements document for a simple personal profile app:
- List every screen the app will have
- For each screen, describe: what the user sees, what the user can do, and what data the screen needs
- Identify which items belong to the UI layer, which to the Business Logic layer, and which to the Data layer
Task 2 — Draw a Separation of Concerns Diagram
Pick one feature (for example, a search function):
- Draw a block diagram with three sections: UI, Business Logic, and Data
- Trace how a user's search input flows from the UI to the Business Logic to the Data layer and back
- Ensure no arrows connect UI directly to Data — they must always pass through Business Logic
UI Best Practices
The build Method and Widget Layout
The build method is where Flutter constructs your UI. Widgets inside it are placed directly under one another and executed using consistent logic.
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('First Item'),
SizedBox(height: 60), // spacing greater than 50 pixels
Text('Second Item'),
SizedBox(height: 60),
Text('Third Item'),
],
);
}Line-by-line explanation:
Column— arranges children vertically, one below the otherSizedBox(height: 60)— adds a gap of 60 pixels between widgets- Each
Textwidget is placed sequentially under the previous one, executed with the same layout logic
The DRY Principle — Code Maintainability
DRY stands for: Don't Repeat Yourself.
The core rule:
If you want to change something and you must change it in more than one place, your code is written poorly. Well-written code allows you to make that change in exactly one place.
Anti-pattern — color repeated in every widget:
Container(color: Color(0xFF1565C0), child: Text('Header')),
Container(color: Color(0xFF1565C0), child: Text('Section')),
Container(color: Color(0xFF1565C0), child: Text('Footer')),Changing the color requires updating three separate lines.
DRY pattern — color defined once:
final primaryColor = Color(0xFF1565C0);
Container(color: primaryColor, child: Text('Header')),
Container(color: primaryColor, child: Text('Section')),
Container(color: primaryColor, child: Text('Footer')),Now you change the color in one place and all three containers update instantly.
User Interaction: Displaying Item Details
When a user clicks on an item, the app should immediately display the details specific to that individual element:
- Step 1: User taps on an item
- Step 2: The app triggers a response that reveals that item's specific details
GestureDetector(
onTap: () {
showDetails(item); // displays details for this specific item
},
child: ListTile(title: Text(item.name)),
)Setting Constraints — Minimum Values
You can define the smallest size an element is allowed to take:
Container(
constraints: BoxConstraints(
minWidth: 100, // smallest possible width
minHeight: 50, // smallest possible height
),
child: Text('Content'),
)This ensures the element never shrinks below the specified threshold regardless of its content.
Reusing Code Logic for Data Reception
When a user finishes making a selection, the app should use consistent, reusable logic to receive and process the selected data:
// Reusable handler called after any selection completes
void onSelectionComplete(SelectedItem item) {
processSelection(item); // same logic handles all selection results
}Task 3 — Refactor Repeated Code
- Open your Flutter project and find any widget where the same style (color, padding, or text style) is written in three or more places
- Extract that style into a single variable or constant
- Replace all occurrences with the variable
- Change the variable once and confirm all instances update simultaneously
Task 4 — Add Interaction and Constraints
- Create a list of three items displayed in a
Column - Wrap each item in a
GestureDetectorthat shows aSnackBarwith that item's name when tapped - Add a
ContainerwithBoxConstraintsthat sets aminWidthof 150 andminHeightof 80 - Verify the container respects the minimum size even when its content is very small
Centralized Theming with Theme.of
The Problem: Hardcoded Colors
When you set a color directly on each widget, the app becomes impossible to update globally:
// BAD — same color hardcoded on every element
ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Color(0xFF1565C0)),
),
child: Text('Submit'),
onPressed: () {},
),
Text('Title', style: TextStyle(color: Color(0xFF1565C0))),
Container(color: Color(0xFF1565C0), child: Text('Box')),To implement dark mode or a new brand color, you must locate and update every single line.
The Solution: Centralized Theming
Define design properties once in a ThemeData object. All widgets reference this single source:
MaterialApp(
theme: ThemeData(
primaryColor: Color(0xFF1565C0),
colorScheme: ColorScheme.fromSeed(
seedColor: Color(0xFF1565C0),
),
),
home: MyHomePage(),
)Accessing the Theme — Theme.of(context)
Instead of a hardcoded color value, reference the central theme:
Container(
color: Theme.of(context).primaryColor,
child: Text(
'Hello',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
)Line-by-line explanation:
Theme.of(context)— looks up theThemeDataobject defined at theMaterialApplevel.primaryColor— retrieves the primary color from that central location.colorScheme.onPrimary— retrieves the text color designed to contrast with the primary color- Changing
primaryColorinThemeDataonce automatically updates every widget that references it
Implementing Global Changes — Dark Mode
With centralized theming, applying dark mode is a single-line change:
MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.dark, // change this one line to switch the entire app
home: MyHomePage(),
)No widget code changes are needed. The entire app updates through the central ThemeData.
flowchart TD
A["ThemeData\nSingle Central Location"] --> B[Button Widget]
A --> C[Text Widget]
A --> D[Container Widget]
A --> E[AppBar Widget]Task 5 — Migrate to Centralized Theming
- Pick a screen that has three or more widgets using the same hardcoded color
- Remove all hardcoded color values from those widgets
- Define a
ThemeDataobject in yourMaterialAppwith that color asprimaryColor - Replace each removed color with
Theme.of(context).primaryColor - Change
primaryColorinThemeDataonce and verify all widgets update simultaneously
Task 6 — Toggle Dark Mode
- Add both
theme: ThemeData.light()anddarkTheme: ThemeData.dark()toMaterialApp - Set
themeMode: ThemeMode.dark - Run the app and observe the complete visual change with zero modifications to any widget
Solving Screen Size and Scrolling Issues
The Device Fragmentation Problem
Your app's design might look perfect on your development device, but end-users have phones with different screen dimensions — different lengths and different widths. A layout that fits your screen may overflow and become invisible on a smaller device.
flowchart TD
A[App looks correct on your device] --> B{User device has smaller screen}
B -->|Widgets fit| C[App displays correctly]
B -->|Widgets overflow| D[Error visible on screen\nContent cut off]
D --> E{Choose a fix}
E --> F[SingleChildScrollView\nWraps the Column]
E --> G[ListView\nReplaces the Column]
E --> H[Expanded\nWidgets resize to fit]When content overflows a Column, Flutter renders a visible overflow error (yellow and black stripes) at the edge of the screen.
The Primary Fix: SingleChildScrollView
Take all the widgets inside your Column and wrap them in a SingleChildScrollView:
// BEFORE — causes overflow on small screens
Column(
children: [
WidgetOne(),
WidgetTwo(),
WidgetThree(),
WidgetFour(),
],
)
// AFTER — user can scroll to see all content
SingleChildScrollView(
child: Column(
children: [
WidgetOne(),
WidgetTwo(),
WidgetThree(),
WidgetFour(),
],
),
)Line-by-line explanation:
SingleChildScrollView— a wrapper widget that enables vertical scrolling for its single child- The
Columnstructure remains unchanged inside it - Users swipe up to reveal widgets that extend beyond the visible screen area
- The overflow error disappears because the content is no longer forced to fit within a fixed boundary
Alternative 1: ListView
Replace the Column entirely with a ListView, which has scrolling built in natively:
ListView(
children: [
WidgetOne(),
WidgetTwo(),
WidgetThree(),
WidgetFour(),
],
)No wrapper needed — ListView handles its own scrolling automatically.
Alternative 2: Expanded
Use Expanded alongside the widgets inside the Column to make them fill and adapt to the available space:
Column(
children: [
Expanded(child: WidgetOne()), // stretches to fill remaining height
WidgetTwo(),
],
)Expanded forces the child to resize and fit within the screen boundaries. The screen does not scroll; instead, the widget adapts its size to fit.
Choosing Between the Three Approaches
| Approach | When to Use |
|---|---|
SingleChildScrollView | Content must stay full-size; user scrolls to see all of it |
ListView | A list of repeating items needs scrolling |
Expanded | Content should shrink to fit the screen without scrolling |
Task 7 — Trigger and Fix an Overflow Error
- Create a
Columnwith 8 largeContainerwidgets, each withheight: 120 - Run the app on a small emulator (4-inch device profile)
- Observe the visible overflow error at the bottom of the screen
- Wrap the
Columnin aSingleChildScrollView - Confirm the error disappears and you can scroll through all containers
Task 8 — Compare All Three Approaches
- Implement the overflowing list using
SingleChildScrollView+Column - Reimplement using only
ListView - Reimplement using
Expanded(observe: content resizes rather than scrolls) - Write a short note comparing what the user experiences in each case
UI Data Models
What Is a Data Model?
A data model is a Dart class that groups related values together. Instead of passing individual, unrelated variables between widgets, you group them into a class. The class becomes the single container for a piece of data and holds those values for as long as they are needed.
Creating a Basic Data Model
// person_model.dart
class PersonModel {
final String name;
final int age;
final DateTime birthDate;
PersonModel({
required this.name,
required this.age,
required this.birthDate,
});
}Line-by-line explanation:
class PersonModel— defines a new Dart class namedPersonModelfinal String name— a value held inside the class: the person's name (cannot be changed after set)final int age— a value held inside the class: the person's agefinal DateTime birthDate— a value held inside the class: the person's date of birthPersonModel({required this.name, ...})— the constructor; all fields must be provided when creating an instance
Connecting a Model to a Widget
Pass the model as a typed parameter to the widget that will display its data:
class PersonCard extends StatelessWidget {
final PersonModel person; // only accepts a PersonModel — no other type
const PersonCard({required this.person, super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(person.name),
Text('Age: ${person.age}'),
Text('Born: ${person.birthDate.year}'),
],
);
}
}Usage:
PersonCard(
person: PersonModel(
name: 'Ahmed',
age: 22,
birthDate: DateTime(2003, 5, 15),
),
)Task 9 — Build a Data Model Class
- Create a new file
lib/models/person_model.dart - Define a
PersonModelclass with three fields:name(String),age(int),birthDate(DateTime) - Add a constructor that requires all three fields
- Create a
StatelessWidgetcalledPersonCardthat accepts aPersonModelas a required parameter - Display all three fields inside the widget using
Textwidgets
Task 10 — Populate a List from Models
- Create a
List<PersonModel>with three entries (different names, ages, birth dates) - Use a
Columnto display aPersonCardfor each item in the list - Verify that changing a value inside the
PersonModelimmediately updates the displayed card
The Logic Layer
What Is the Logic Layer?
The logic layer contains the calculations and processing that your app performs, completely separated from the UI. In this part of the lab, the app builds a widget that calculates the number of days associated with a specific person. Calculating days is a business logic task, not a UI task — the UI only displays the result.
Strict Data Typing in Widgets
A key principle: a widget must receive exactly the data type it expects. If the widget is designed to work with a PersonModel, passing any other type is an error that Dart will catch at compile time.
class DaysCalculatorWidget extends StatelessWidget {
final PersonModel person; // strictly requires a PersonModel
const DaysCalculatorWidget({required this.person, super.key});
int calculateDaysAlive() {
final today = DateTime.now();
return today.difference(person.birthDate).inDays;
}
@override
Widget build(BuildContext context) {
return Text('Days alive: ${calculateDaysAlive()}');
}
}Line-by-line explanation:
final PersonModel person— the constructor enforces strict typing; only aPersonModelis acceptedcalculateDaysAlive()— the logic function, completely separated from the build methodDateTime.now().difference(person.birthDate).inDays— subtracts the birth date from today and returns the result in full daysbuild— only displays the result; it does not perform the calculation itself
The Logic Implementation Workflow
Before writing custom logic from scratch, always ask:
Do I actually need to build this myself, or does a ready-made solution already exist?
flowchart TD
A[Identify the logic needed] --> B{Search Flutter Pub}
B -->|Package exists| C[Evaluate the package]
B -->|No package found| D[Write custom logic]
C --> E{Check its dependencies}
E --> F[Integrate the package]
D --> G[Implement from scratch]
F --> H[Logic complete]
G --> HSearching Flutter Pub for Packages
The Flutter package repository (pub.dev) hosts packages created by other developers. Before implementing complex logic manually:
- Go to pub.dev
- Search for keywords related to your task (for example: "date", "time difference", "age calculator")
- Evaluate the package's popularity, maintenance status, and license
- Read its documentation and check its dependencies — some packages require other packages to function
Understanding Dependencies
A dependency is when one package relies on another to work correctly:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
intl: ^0.19.0 # date formatting package added from PubHow dependencies work:
- Package A uses Package B internally
- When you add Package A to your project, Package B must also be present
- Running
flutter pub getresolves and downloads all required dependencies automatically
Building an Isolated Logic Class
Creating a complete class and verifying it independently is an excellent approach to logic development:
// lib/logic/days_calculator.dart
class DaysCalculator {
final DateTime birthDate;
DaysCalculator(this.birthDate);
int calculateDaysAlive() {
return DateTime.now().difference(birthDate).inDays;
}
bool isOver1000DaysOld() {
return calculateDaysAlive() > 1000;
}
}Line-by-line explanation:
class DaysCalculator— a pure logic class: no widgets, no UI, noBuildContextfinal DateTime birthDate— the only input the class needscalculateDaysAlive()— computes and returns the number of days since birthisOver1000DaysOld()— usescalculateDaysAlive()internally to answer a boolean question- This class can be tested independently without running the full app
Verifying the class:
void main() {
final calc = DaysCalculator(DateTime(2003, 5, 15));
print(calc.calculateDaysAlive()); // prints the number of days
print(calc.isOver1000DaysOld()); // prints true or false
}Event Handling in the Logic Layer
The logic layer also handles events — situations where something happens and the logic layer must take over to process it. The UI triggers the event; the logic layer processes it; the result goes back to the UI.
// When a selection event occurs, the logic layer takes over
void onPersonSelected(PersonModel selectedPerson) {
final calculator = DaysCalculator(selectedPerson.birthDate);
final days = calculator.calculateDaysAlive();
displayResult(days); // passes the result back to the UI
}The logic layer never knows or cares how the result will be displayed.
Task 11 — Build a Type-Strict Days Calculator Widget
- Use the
PersonModelfrom the previous section - Create a
DaysCalculatorWidgetthat accepts only aPersonModelas its required parameter - Implement
calculateDaysAlive()inside the widget usingDateTime.now().difference(person.birthDate).inDays - Display the result in a
Textwidget - Attempt to pass a plain
intinstead of aPersonModelto confirm Dart rejects it at compile time
Task 12 — Research and Integrate a Package
- Open pub.dev in your browser
- Search for "date difference" or "age calculator"
- Find a package that computes date intervals
- Add it to
pubspec.yamland runflutter pub get - Read the package documentation and identify every dependency it requires
- Rewrite your days calculation using the package
- Confirm the output matches your manual calculation
Task 13 — Isolate Logic into a Separate Class
- Create a new file
lib/logic/days_calculator.dart - Define a
DaysCalculatorclass with abirthDateproperty - Add
calculateDaysAlive()returning anint - Add
isOver1000DaysOld()returning abool - Run a quick test using
void main()to print both results for a known birth date - Connect the class to
DaysCalculatorWidgetby creating an instance inside the widget
State Management with StatefulWidget
What Is State?
State is any data that can change over time and whose change should cause the UI to update. In the Age Calculator, the selected dates and computed results are state — when the user picks a new date, the UI must re-render to show it.
A widget that holds no mutable data is a StatelessWidget. A widget that owns data that changes at runtime is a StatefulWidget.
StatelessWidget vs StatefulWidget
| Type | When to Use | Can Change After Build? |
|---|---|---|
StatelessWidget | Display-only, no interaction | No |
StatefulWidget | User input, computed results, anything that updates | Yes, via setState() |
The StatefulWidget Pattern
A StatefulWidget is split into two classes:
// 1. The widget itself — immutable, holds configuration
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
// 2. The State object — holds the mutable data
class _HomeScreenState extends State<HomeScreen> {
DateTime _birthDate = DateTime(2000, 1, 1); // mutable state
Map<String, int>? _age; // mutable state
@override
Widget build(BuildContext context) {
return Text('$_birthDate');
}
}Line-by-line explanation:
StatefulWidget— the widget class itself is still immutable; it just knows how to create aStatecreateState()— returns theStateobject that will own the mutable data_HomeScreenState— the private class where all mutable fields liveDateTime _birthDate— a state variable; changing it triggers a rebuild
setState() — The Rebuild Trigger
setState() is the mechanism that tells Flutter: "data changed — rebuild the UI."
// Without setState: data changes but UI stays the same (stale)
_birthDate = newDate; // UI never updates
// With setState: data changes AND Flutter schedules a rebuild
setState(() {
_birthDate = newDate; // UI updates on next frame
});How it works:
- You call
setState(() { ... })with the data change inside the closure - Flutter marks this widget as "dirty"
- On the next frame, Flutter calls
build()again with the new data - The UI reflects the updated state
Connecting State to the Three-Layer Architecture
void _calculate() {
// DATA LAYER: build the typed model from current state
final model = AgeInputModel(
birthDate: _birthDate,
todayDate: _todayDate,
);
// BUSINESS LOGIC LAYER: run the calculation
final calculator = AgeCalculator(model);
// UI LAYER: setState triggers rebuild with new results
setState(() {
_age = calculator.calculateAge();
_nextBirthday = calculator.calculateNextBirthday();
});
}The flow: user taps CALCULATE → UI layer reads state → creates Data model → passes to Logic layer → Logic returns result → setState() updates UI.
Task 14 — Add State to the Age Calculator
- Convert
HomeScreenfromStatelessWidgettoStatefulWidget - Declare
_birthDate,_todayDate,_age, and_nextBirthdayas state variables - Call
setState()insideonDateSelectedso the date field updates when the user picks a date - Call
setState()inside_calculate()after runningAgeCalculatorto show results - Call
setState()inside_clear()to reset all fields - Verify: pick a new date → field shows new date; press CALCULATE → result boxes fill in; press CLEAR → boxes reset to zero
Summary
| Topic | Key Concept |
|---|---|
| Requirements | Define what the app needs before writing any code |
| UI Best Practices | DRY principle: change in one place, not many |
| Theming | Theme.of(context) for centralized color management |
| Scrolling | SingleChildScrollView fixes overflow on small screens |
| UI Models | Dart classes hold structured data for widgets |
| Logic Layer | Separate calculations from UI; check Flutter Pub first |
| State Management | StatefulWidget + setState() for mutable UI data |
The most important takeaway: architecture comes first. Planning the three layers — UI, Business Logic, and Data — before writing code is what makes a Flutter app maintainable and professional.