DOCODIVE
Mobile Apps Free Learning Path Intermediate

Mobile App Development — Intermediate Guide

Level up your Flutter skills: state management, REST APIs, JSON, local storage, media, and real-world architecture.

8–10 weeks 30 lessons Flutter + Dart
01

Responsive UI & Screen Sizes

25 min

Real apps run on hundreds of different screen sizes. This lesson teaches you to build layouts that look great on every phone and tablet.

What you'll learn

  • Understand screen size and orientation
  • Use MediaQuery to adapt layouts
  • Build responsive Flutter UIs

Concept

Different devices have different widths, heights, and pixel densities. Flutter gives you MediaQuery to read the screen dimensions at runtime, so you can adjust padding, font sizes, and layouts based on the available space.

How it works

MediaQuery.of(context).size.width gives the screen width. You can check if the width is below a breakpoint and choose a different layout. LayoutBuilder gives the constraints of a specific parent, which is even more useful for reusable widgets.

Example

LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth > 600) { return Row(children: [/* desktop layout */]); } return Column(children: [/* mobile layout */]); }, )

Understand it

LayoutBuilder checks the space a widget actually has, not the whole screen. This makes the widget reusable anywhere and it will always adapt correctly.

⚠️ Common Mistake Hardcoding sizes in pixels — what looks right on your emulator may overflow on a smaller phone.
💡 Pro Tip Use MediaQuery for top-level decisions and LayoutBuilder for reusable widgets.
✅ Key takeaway: Responsive UI = adapt to available space using MediaQuery or LayoutBuilder.
Adaptive and responsive design

Practice

Build a widget that shows two elements side by side on wide screens and stacked on narrow screens.

Use LayoutBuilder and check constraints.maxWidth.
✓ LayoutBuilder(builder: (c, b) => b.maxWidth > 600 ? Row(children: [...]) : Column(children: [...]))
01 / 30 Next
02

Advanced Flutter Layouts

30 min

Beyond Rows and Columns, Flutter offers powerful layout widgets like Expanded, Flexible, Stack, and GridView for complex screens.

What you'll learn

  • Master Expanded and Flexible
  • Layer widgets with Stack
  • Create grids with GridView

Concept

Expanded and Flexible control how children share available space inside a Row or Column. Stack layers widgets on top of each other, useful for badges and overlays. GridView arranges items in a scrollable grid.

How it works

Expanded forces a child to fill remaining space. Flexible lets a child take up to a given fraction. Stack's children are positioned with Positioned or alignment. GridView.builder creates a scrollable grid efficiently.

Example

Row( children: [ Expanded(flex: 2, child: Container(color: Colors.blue)), Expanded(flex: 1, child: Container(color: Colors.red)), ], )

Understand it

The 'flex' value determines the ratio — a flex of 2 gets twice as much space as flex 1.

⚠️ Common Mistake Using Flexible with a child that needs unbounded height — it causes layout errors.
💡 Pro Tip Use Expanded for one flexible child and Flexible when multiple children share space.
✅ Key takeaway: Expanded, Stack, and GridView unlock complex screen designs.
Flutter layout guide

Practice

Create a screen with a title at the top, a grid of 6 items below, and a bottom button.

Use Column with Expanded GridView and a bottom button.
✓ Column(children: [Text('Title'), Expanded(child: GridView.builder(...)), ElevatedButton(...)])
Previous 02 / 30 Next
03

Reusable Custom Widgets

30 min

Don't repeat the same UI code in every screen. Extract common pieces into reusable custom widgets for cleaner, maintainable code.

What you'll learn

  • Identify repeated UI patterns
  • Create custom StatelessWidget components
  • Pass data via constructors

Concept

When you write the same card, button, or list tile in multiple places, extract it into its own widget class. The widget takes the changing parts as constructor parameters, so each use is slightly different without duplicating code.

How it works

Create a new widget class that accepts parameters like title and onTap. Return a styled Container or Card from its build method. Use the widget everywhere, passing different values each time.

Example

class CustomCard extends StatelessWidget { final String title; final IconData icon; const CustomCard({super.key, required this.title, required this.icon}); @override Widget build(BuildContext context) { return Card( child: ListTile( leading: Icon(icon), title: Text(title), ), ); } } // Use it: CustomCard(title: 'Profile', icon: Icons.person), CustomCard(title: 'Settings', icon: Icons.settings),

Understand it

The constructor parameters are how you make one widget flexible for many different uses — exactly like Flutter's own widgets.

⚠️ Common Mistake Copy-pasting UI code between screens, then having to fix the same bug in five places.
💡 Pro Tip If you copy-paste a widget more than twice, extract it into a custom widget.
✅ Key takeaway: Custom widgets = write once, reuse everywhere, change in one place.
Flutter widgets catalog

Practice

Extract a repeated info card from your app into a reusable widget.

Pass the changing text and icon as constructor parameters.
✓ Create a widget class with title and subtitle parameters, then use it wherever the card appears.
Previous 03 / 30 Next
04

Themes & Dark Mode

25 min

Consistent colors and typography make an app look professional. Themes let you define them once and apply them everywhere.

What you'll learn

  • Define a light theme
  • Add dark mode
  • Style text and buttons consistently

Concept

Flutter's ThemeData describes the colors, fonts, and component styles for your whole app. You pass it to MaterialApp. Using theme colors like Theme.of(context).colorScheme.primary keeps everything consistent, and dark mode is as simple as providing a darkTheme.

How it works

In MaterialApp, set theme: ThemeData.light() and darkTheme: ThemeData.dark(). Flutter automatically switches based on the system setting. Reference theme colors in widgets instead of hardcoded Color values.

Example

MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), ), darkTheme: ThemeData.dark(), home: const HomeScreen(), )

Understand it

When you use Theme.of(context).colorScheme.primary, the widget updates automatically when the theme changes — no manual color updates needed.

⚠️ Common Mistake Hardcoding colors like Colors.red everywhere instead of using theme colors, which breaks dark mode.
💡 Pro Tip Define your seed color once and let Flutter generate the full light and dark palettes automatically.
✅ Key takeaway: Themes centralize styling; dark mode is nearly free once you use theme colors.
Use themes in Flutter

Practice

Enable dark mode on your app and confirm all screens switch correctly.

Replace hardcoded colors with Theme.of(context) references.
✓ Set darkTheme in MaterialApp and use theme colors everywhere; toggle system dark mode to verify.
Previous 04 / 30 Next
05

Forms & Form Validation

30 min

Forms collect user data. This lesson teaches Flutter's Form widget and how to validate input before submitting.

What you'll learn

  • Build a form with multiple fields
  • Validate input with validators
  • Show helpful error messages

Concept

The Form widget wraps TextFormField widgets and manages their validation state. Each field has a validator function that returns an error message if the input is invalid. Calling formKey.currentState.validate() runs all validators at once.

How it works

Create a GlobalKey<FormState>, pass it to the Form, then call validate() on submit. TextFormField's validator gets the current value and returns null if valid, or a string error if not.

Example

final _formKey = GlobalKey<FormState>(); Form( key: _formKey, child: Column(children: [ TextFormField( validator: (value) => value == null || value.isEmpty ? 'Required' : null, decoration: const InputDecoration(labelText: 'Email'), ), ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { print('Valid!'); } }, child: const Text('Submit'), ), ]), )

Understand it

Each validator returns null for valid input or an error string for invalid. validate() returns true only if every field is valid.

⚠️ Common Mistake Forgetting to return null from a validator when the input is valid — the field always shows an error.
💡 Pro Tip Use autovalidateMode: AutovalidateMode.onUserInteraction for live validation as the user types.
✅ Key takeaway: Form + validators = clean input collection with instant error feedback.
Form validation cookbook

Practice

Add an email and password field, requiring both before submit.

Use two TextFormFields with non-empty validators.
✓ Two TextFormFields with validator checking value.isEmpty, and submit only if _formKey.validate().
Previous 05 / 30 Next
06

Advanced Navigation

30 min

Basic push/pop is fine for two screens, but real apps need tabs, bottom bars, and passing results back. This lesson covers those patterns.

What you'll learn

  • Pass data back from a screen
  • Use bottom navigation bars
  • Handle navigation results

Concept

When a screen opens another, the second screen can return a result using Navigator.pop(context, result). The first screen awaits that result with await Navigator.push(...). Bottom navigation uses a BottomNavigationBar that switches between screens without pushing them onto the stack.

How it works

The first screen does 'final result = await Navigator.push(...)'. The second screen calls 'Navigator.pop(context, data)' to return data. BottomNavigationBar swaps the body based on the selected index.

Example

// First screen final result = await Navigator.push( context, MaterialPageRoute(builder: (c) => const SecondScreen()), ); print('Result: $result'); // 'Hello back!' // Second screen Navigator.pop(context, 'Hello back!');

Understand it

The 'await' pauses the first screen until the second pops, then gives you the returned value. This is how forms return their data to the previous screen.

⚠️ Common Mistake Forgetting to await Navigator.push, so the result is used before it is available.
💡 Pro Tip Use bottom navigation for switching top-level tabs; use push/pop for drill-down flows.
✅ Key takeaway: Navigation isn't just forward — screens can return data with pop and await.
Return data from a screen

Practice

Open a picker screen from a main screen and display the picked item when you return.

await Navigator.push, then use the returned value in setState.
✓ final picked = await Navigator.push(...); setState(() => _selected = picked);
Previous 06 / 30 Next
07

Named Routes & Navigation Patterns

30 min

As apps grow, hardcoded MaterialPageRoutes become messy. Named routes centralize navigation and make deep links easier.

What you'll learn

  • Define named routes
  • Navigate with route names
  • Use onGenerateRoute for arguments

Concept

Named routes map simple string names like '/home' to builder functions. This centralizes all navigation and lets you push by name instead of constructing widgets inline. You can also pass arguments through onGenerateRoute.

How it works

Define routes in MaterialApp's routes parameter. Navigate with Navigator.pushNamed(context, '/second'). For dynamic arguments, use onGenerateRoute to read settings.arguments.

Example

MaterialApp( routes: { '/': (context) => const HomeScreen(), '/second': (context) => const SecondScreen(), }, ); // Navigate Navigator.pushNamed(context, '/second');

Understand it

Route names are string keys. Push by name keeps navigation logic in one place, so renaming or changing a screen is simpler.

⚠️ Common Mistake Using names that don't match the routes map — the app throws an error at runtime.
💡 Pro Tip Use named routes for top-level screens; reserve push with MaterialPageRoute for simple one-off flows.
✅ Key takeaway: Named routes centralize navigation for larger, maintainable apps.
Named routes in Flutter

Practice

Define two named routes and switch between them using pushNamed.

Add a routes map to MaterialApp and use Navigator.pushNamed.
✓ routes: {'/a': (c) => A(), '/b': (c) => B()} and Navigator.pushNamed(context, '/b').
Previous 07 / 30 Next
08

State Management Concepts

30 min

As your app grows, basic setState becomes hard to manage. This lesson explains the broader state management landscape.

What you'll learn

  • Understand local vs shared state
  • Know the main state management approaches
  • Choose the right tool for your app

Concept

Local state affects one widget (a counter). Shared state affects many widgets (logged-in user, cart contents). setState works for local state, but shared state needs a state management solution like Provider, Riverpod, Bloc, or Riverpod to synchronize widgets across the app.

How it works

State management libraries provide a way to store state outside the widget tree and notify listeners when it changes. Widgets subscribe to only the state they need, avoiding rebuilding the whole app on every change.

Example

A cart's item count is shared state — many screens show it. Updating it should notify all of them; a state solution handles this automatically.

Understand it

The core problem is 'how do multiple widgets stay in sync when shared data changes?'. Every state library answers this question differently.

⚠️ Common Mistake Using setState for everything, leading to deeply nested state and bugs that are hard to trace.
💡 Pro Tip Start with setState; reach for Provider when you notice multiple widgets need the same data.
✅ Key takeaway: Local state is easy with setState; shared state needs a proper management solution.
State management introduction

Practice

Identify which parts of a simple shop app are local state and which are shared state.

Think: does more than one screen need this data?
✓ Cart contents and user login are shared; a single screen's input text is local.
Previous 08 / 30 Next
09

Provider State Management

35 min

Provider is Flutter's recommended, beginner-friendly state management solution. This lesson shows how to use it for shared state.

What you'll learn

  • Set up Provider
  • Read and update shared state
  • Notify listeners efficiently

Concept

Provider makes a state object available to widgets below it in the tree. Widgets read it with context.watch or context.read, and when you call notifyListeners(), all watching widgets rebuild with the latest value.

How it works

Wrap your app in a ChangeNotifierProvider. Create a class that extends ChangeNotifier and holds mutable state. In widgets, use context.watch<MyModel>() to rebuild on changes, and context.read<MyModel>() to call methods without rebuilding.

Example

class Counter extends ChangeNotifier { int _value = 0; int get value => _value; void increment() { _value++; notifyListeners(); } } // Provider setup ChangeNotifierProvider( create: (_) => Counter(), child: MaterialApp(home: HomeScreen()), )

Understand it

notifyListeners() is the key — it signals every listening widget to rebuild. Widgets that use context.watch are the listeners.

⚠️ Common Mistake Calling notifyListeners during a build phase, which throws an error. Always call it in response to user actions or after async work.
💡 Pro Tip Use context.read for one-time reads and context.watch for reactive updates.
✅ Key takeaway: Provider = shared state object + notifyListeners + context.watch.
Provider package

Practice

Convert a counter app from setState to Provider.

Create a ChangeNotifier, provide it, and use context.watch in the UI.
✓ Wrap app in ChangeNotifierProvider, move counter into the model, and replace setState with notifyListeners.
Previous 09 / 30 Next
10

Riverpod Basics

35 min

Riverpod is Provider's more powerful, compile-safe successor. It moves state definition outside the widget tree, fixing several Provider limitations.

What you'll learn

  • Understand Riverpod's core concepts
  • Create a simple provider
  • Use ConsumerWidget to read state

Concept

Riverpod defines state in top-level 'providers' that can be read from anywhere without a BuildContext. Widgets use ConsumerWidget (or Consumer) to watch providers and rebuild when they change. Riverpod is compile-safe, so many errors are caught at compile time instead of runtime.

How it works

Create a StateProvider or ChangeNotifierProvider as a final global variable. In a ConsumerWidget, use ref.watch(myProvider) to get the value and ref.read for methods. Riverpod automatically handles dependency injection and disposal.

Example

final counterProvider = StateProvider<int>((ref) => 0); class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider); return Text('$count'); } }

Understand it

Unlike Provider, providers are global — no BuildContext required, so there's no chance of 'Provider not found' errors.

⚠️ Common Mistake Using context.watch in Riverpod where ref.watch is required — the two APIs are similar but not interchangeable.
💡 Pro Tip Use StateProvider for simple values; use StateNotifierProvider for complex logic.
✅ Key takeaway: Riverpod = compile-safe, context-free state management.
Riverpod in Flutter

Practice

Create a simple Riverpod counter app with an increment button.

Use StateProvider and ref.watch/ref.read in a ConsumerWidget.
✓ final count = ref.watch(counterProvider); ElevatedButton(onPressed: () => ref.read(counterProvider.notifier).state++, child: Text('$count'))
Previous 10 / 30 Next
11

Working with REST APIs

30 min

Most real apps get their data from a backend over HTTP. This lesson introduces REST APIs and how apps communicate with them.

What you'll learn

  • Understand what REST APIs are
  • Know HTTP methods and status codes
  • Learn request/response basics

Concept

A REST API is a web service that exposes resources through URLs. You use HTTP methods — GET to fetch, POST to create, PUT/PATCH to update, DELETE to remove. Responses come as JSON with status codes (200 success, 404 not found, 500 error).

How it works

Your app sends an HTTP request to a URL like 'https://api.example.com/users'. The server processes it and returns a response with a status code and a body, usually JSON. Your app parses that JSON and updates the UI.

Example

GET /users → fetch list of users POST /users → create a new user GET /users/1 → fetch one user DELETE /users/1 → remove user 1

Understand it

Each URL represents a resource. The HTTP method defines the action. Status codes tell you whether the request succeeded and why it failed.

⚠️ Common Mistake Ignoring HTTP status codes — a 404 or 500 still returns a body, and treating it as success causes crashes.
💡 Pro Tip Use a REST client like Postman to test APIs before writing Flutter code.
✅ Key takeaway: REST = resources + HTTP methods + JSON responses.
Flutter networking guide

Practice

Identify the correct HTTP method for fetching, creating, and deleting a book in an API.

Match verbs to actions.
✓ GET for fetching, POST for creating, DELETE for removing.
Previous 11 / 30 Next
12

HTTP Requests in Flutter

30 min

Time to actually call an API from Flutter. This lesson shows how to use the http package for real requests.

What you'll learn

  • Add the http package
  • Make GET and POST requests
  • Handle responses and errors

Concept

Flutter has no built-in HTTP client, so you add the 'http' package via pubspec.yaml. The package provides top-level functions like http.get() and http.post() that return a Future<Response>. The response has a statusCode and a body string.

How it works

Add http to dependencies, import 'package:http/http.dart', then call 'await http.get(Uri.parse(url))'. Check response.statusCode, then process response.body (usually JSON).

Example

import 'package:http/http.dart' as http; Future<void> fetchData() async { final response = await http.get(Uri.parse('https://api.example.com/data')); if (response.statusCode == 200) { print(response.body); } else { print('Failed: ${response.statusCode}'); } }

Understand it

http.get returns a Future, so you must await it. The Response object separates the status code from the body, letting you handle errors properly.

⚠️ Common Mistake Forgetting to add the http package to pubspec.yaml and running 'flutter pub get'.
💡 Pro Tip Use Uri.parse for URLs — plain strings are error-prone and may not encode special characters.
✅ Key takeaway: http package + await + statusCode check = basic API call in Flutter.
http package

Practice

Make a GET request to a public API and print the status code.

Use http.get with Uri.parse and check response.statusCode.
✓ final r = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')); print(r.statusCode);
Previous 12 / 30 Next
13

JSON Basics

25 min

JSON is the language of APIs. This lesson teaches you to read, write, and understand JSON before parsing it in Dart.

What you'll learn

  • Understand JSON structure
  • Read nested JSON
  • Recognize common JSON pitfalls

Concept

JSON (JavaScript Object Notation) is a lightweight text format for structured data. It uses key-value pairs inside braces, arrays inside brackets, and supports strings, numbers, booleans, and null. Almost every modern API returns JSON.

How it works

A JSON object looks like {'name': 'Ali', 'age': 25}. Arrays look like [1, 2, 3]. Nested structures combine both. Dart's jsonDecode converts a JSON string into Map and List objects.

Example

{ "user": { "name": "Ali", "age": 25, "hobbies": ["reading", "coding"] } }

Understand it

In JSON, keys are always strings in quotes. A JSON object becomes a Dart Map, and a JSON array becomes a Dart List when decoded.

⚠️ Common Mistake Confusing JSON (a string format) with a Dart Map (an in-memory object) — you must decode JSON before using it.
💡 Pro Tip Use jsonDecode from 'dart:convert' to turn a JSON string into Dart data.
✅ Key takeaway: JSON = text format; jsonDecode turns it into Dart Maps and Lists.
JSON and serialization

Practice

Write a JSON object representing a book with a title, author, and list of tags.

Use braces, key-value pairs, and an array.
✓ {"title": "Flutter", "author": "Google", "tags": ["mobile", "dart"]}
Previous 13 / 30 Next
14

JSON Parsing & Serialization

35 min

Now you take JSON from an API and turn it into Dart objects you can actually use in your app.

What you'll learn

  • Decode JSON strings
  • Access nested values safely
  • Convert Dart objects back to JSON

Concept

Parsing is converting JSON text into Dart objects with jsonDecode. Serialization is the reverse — converting Dart objects back into JSON strings with jsonEncode. For complex responses, manual parsing gives you full control and performance.

How it works

Call jsonDecode(response.body) to get a Map or List. Access values with keys like map['name']. Cast types explicitly since JSON values are dynamic. For nested data, navigate with multiple key accesses or casts.

Example

final data = jsonDecode(response.body); final name = data['user']['name'] as String; final age = data['user']['age'] as int; final jsonString = jsonEncode({'name': 'Ali', 'age': 25});

Understand it

jsonDecode returns dynamic, so casting to String, int, or your own types makes the data safe to use in typed Dart code.

⚠️ Common Mistake Accessing a key that doesn't exist in the JSON — it throws a null error. Always guard against missing keys.
💡 Pro Tip Use the 'as' cast after checking the type, and provide defaults for optional fields.
✅ Key takeaway: Parsing = JSON to Dart; serialization = Dart back to JSON.
Manual JSON decoding

Practice

Parse a JSON string containing a list of three names and print each one.

Use jsonDecode and iterate over the list.
✓ final names = jsonDecode('["Ali","Sara","Omar"]'); names.forEach(print);
Previous 14 / 30 Next
15

Models & Data Classes

35 min

Raw Maps are hard to work with. Data classes give you typed, named access to your API data and make the code much cleaner.

What you'll learn

  • Create simple model classes
  • Write fromJson and toJson methods
  • Use models instead of raw Maps

Concept

A model class represents one type of data — a User, a Product, a Post. It has typed fields, a constructor, and fromJson/toJson methods to convert between the class and JSON. Using models makes your code readable, safe, and self-documenting.

How it works

Write a class with final fields. The fromJson factory takes a Map and returns an instance. The toJson method converts the instance back to a Map. jsonDecode + fromJson gives you typed objects from any API.

Example

class User { final String name; final int age; User({required this.name, required this.age}); factory User.fromJson(Map<String, dynamic> json) { return User( name: json['name'] as String, age: json['age'] as int, ); } Map<String, dynamic> toJson() => {'name': name, 'age': age}; }

Understand it

fromJson centralizes parsing logic in one place. Everywhere else in your app, you work with a typed User object instead of guessing what keys exist.

⚠️ Common Mistake Writing parsing logic inline in every screen instead of putting it in a model's fromJson method.
💡 Pro Tip Use json_serializable or freezed for automatic code generation once you have many models.
✅ Key takeaway: Models give typed, safe access to API data instead of raw Maps.
Code generation for models

Practice

Create a Post model with id, title, and body, including fromJson.

Use a factory constructor and type casts.
✓ class Post { final int id; final String title; final String body; Post(...); factory Post.fromJson(Map<String,dynamic> j) => Post(id: j['id'], title: j['title'], body: j['body']); }
Previous 15 / 30 Next
16

Loading, Error & Empty States

30 min

Real apps don't just show data — they show a spinner while loading, an error when something fails, and a message when there's no data.

What you'll learn

  • Track loading state
  • Handle errors gracefully
  • Show empty states

Concept

Every network call goes through three phases: loading, success, or error. Good UX shows a spinner during loading, an error message with retry on failure, and a friendly message when the result is empty.

How it works

Keep an enum or boolean for loading state, and a separate error value. In the build method, check loading first (show spinner), then error (show error UI), then empty data (show empty UI), and finally show the data.

Example

if (_isLoading) { return const Center(child: CircularProgressIndicator()); } else if (_error != null) { return Center(child: Text('Error: $_error')); } else if (_items.isEmpty) { return const Center(child: Text('No items found')); } else { return ListView.builder(...); }

Understand it

By handling states in order, you never show a broken UI — the user always sees something meaningful.

⚠️ Common Mistake Forgetting the loading state and showing an empty list briefly before data arrives, causing flicker.
💡 Pro Tip Add a 'Retry' button to error states so users can recover without restarting the app.
✅ Key takeaway: Loading → error → empty → data: handle all four states for a polished app.
Fetch data from the internet

Practice

Add a loading spinner, error message, and empty state to a list screen.

Use a boolean for loading and a string for error.
✓ Branch in build(): if loading show spinner, else if error show message, else if empty show 'No data', else show list.
Previous 16 / 30 Next
17

Async Programming & Futures

30 min

Network calls and file reads take time. Dart's async model uses Futures to handle work that completes later without freezing the app.

What you'll learn

  • Understand async vs sync
  • Work with Future
  • Handle async results

Concept

A Future represents a value that will be available at some point in the future. When you call an async function, it returns immediately with a Future, and the actual work happens later. You use await to pause until the value is ready, keeping the UI responsive.

How it works

An async function returns a Future. Calling it starts the work; you use 'await' inside another async function to get the result. If you don't await, you get the Future itself, not the value.

Example

Future<String> fetchName() async { await Future.delayed(const Duration(seconds: 2)); return 'Ali'; } void main() async { final name = await fetchName(); print(name); }

Understand it

The async/await syntax makes asynchronous code read like synchronous code, but under the hood, execution pauses and resumes when the work completes.

⚠️ Common Mistake Forgetting to await a Future and then trying to use the value — you get a 'Future<String>' where you expected a String.
💡 Pro Tip Never block the UI thread — anything slow (network, file, database) should be async.
✅ Key takeaway: Future = a value arriving later; await = wait for it without freezing the UI.
Async and await in Dart

Practice

Write a function that returns a Future with a delay, and await it from main.

Use async, await, and Future.delayed.
✓ Future<int> fetch() async { await Future.delayed(Duration(seconds: 1)); return 42; } void main() async { print(await fetch()); }
Previous 17 / 30 Next
18

Async/Await & FutureBuilder

30 min

FutureBuilder is Flutter's way to render UI based on a Future's state — perfect for showing spinners and data without manual state flags.

What you'll learn

  • Use FutureBuilder
  • Handle Future states cleanly
  • Convert async data into UI

Concept

FutureBuilder takes a Future and a builder function. While the Future is pending, it shows one UI; when it completes with data or an error, it shows another. It rebuilds automatically when the Future resolves, removing the need for manual loading flags.

How it works

Pass a Future to FutureBuilder. In the builder, check snapshot.connectionState and snapshot.hasData/hasError to decide what to render.

Example

FutureBuilder<String>( future: fetchName(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const CircularProgressIndicator(); } if (snapshot.hasError) { return Text('Error: ${snapshot.error}'); } return Text(snapshot.data ?? 'No data'); }, )

Understand it

The snapshot carries everything — connection state, data, and error. You don't need separate loading and error variables.

⚠️ Common Mistake Creating the Future inside the build method — it restarts on every rebuild, causing infinite loops.
💡 Pro Tip Store the Future in a State field or a final variable so it is not recreated on each build.
✅ Key takeaway: FutureBuilder turns a Future into reactive UI — no manual state flags needed.
FutureBuilder API

Practice

Use FutureBuilder to show a spinner then a name when a Future completes.

Pass a Future and check snapshot.connectionState.
✓ FutureBuilder(future: fetchName(), builder: (c, s) => s.connectionState == waiting ? spinner : Text(s.data))
Previous 18 / 30 Next
19

Local Storage Concepts

25 min

Not all data lives on a server. Apps store preferences, caches, and offline data locally on the device.

What you'll learn

  • Understand local storage options
  • Know when to use each option
  • Plan offline data strategy

Concept

Local storage keeps data on the device for speed, offline access, and small user preferences. Main options are Shared Preferences (simple key-value pairs), SQLite (structured relational data), and files (large data like images). Choosing the right one depends on what you are storing.

How it works

Shared Preferences is ideal for settings and tiny data. SQLite is for structured, queryable data like tasks or notes. Files store large binary data like downloaded images or documents.

Example

Storing whether a user is logged in → Shared Preferences. Storing a list of tasks with fields → SQLite. Caching an image → file storage.

Understand it

Match the storage type to the data structure. Key-value for settings, relational for records, files for media.

⚠️ Common Mistake Using SQLite for everything, even a single boolean flag — it adds unnecessary complexity.
💡 Pro Tip Start with Shared Preferences for simple needs; move to SQLite only when you need queries and relationships.
✅ Key takeaway: Local storage = preferences, structured data, and files — each with its own tool.
Flutter persistence

Practice

Decide the best storage for: theme preference, a contact list, and a cached image.

Think data size and structure.
✓ Theme → Shared Preferences, contacts → SQLite, image → file.
Previous 19 / 30 Next
20

Shared Preferences

30 min

Shared Preferences is the simplest way to store small bits of data like settings, flags, and tokens on the device.

What you'll learn

  • Add shared_preferences package
  • Store and read simple values
  • Use it for app settings

Concept

shared_preferences is a Flutter plugin that stores key-value pairs persistently. It's best for small amounts of simple data — booleans, strings, integers. Data survives app restarts and is perfect for settings like dark mode or 'onboarding seen'.

How it works

Get an instance with SharedPreferences.getInstance(), then call setString, setBool, setInt to write and getString, getBool, getInt to read. Values persist automatically.

Example

final prefs = await SharedPreferences.getInstance(); await prefs.setBool('isDarkMode', true); final dark = prefs.getBool('isDarkMode') ?? false; print(dark); // true

Understand it

All read/write methods are async. Use '??' defaults when reading, because a key might not exist yet.

⚠️ Common Mistake Storing large or complex data in Shared Preferences — it's meant for tiny key-value pairs, not full datasets.
💡 Pro Tip Use a central settings class to wrap Shared Preferences so keys are defined in one place.
✅ Key takeaway: Shared Preferences = persistent key-value storage for simple settings.
shared_preferences package

Practice

Store and retrieve a user's display name using Shared Preferences.

Use setString and getString with await.
✓ final prefs = await SharedPreferences.getInstance(); await prefs.setString('name', 'Ali'); final name = prefs.getString('name');
Previous 20 / 30 Next
21

SQLite / Local Database

35 min

For structured data that needs querying, SQLite is the standard local database on mobile. This lesson introduces the sqlite3-based approach.

What you'll learn

  • Understand SQLite basics
  • Know how Flutter integrates SQLite
  • Plan a local database schema

Concept

SQLite is a lightweight relational database that stores data in tables with rows and columns. In Flutter, the sqflite plugin provides an easy API for creating tables, inserting rows, and querying data with SQL. It's perfect for offline-first apps and structured records.

How it works

Open a database, create a table with columns, then use SQL commands — INSERT, SELECT, UPDATE, DELETE — through the sqflite API. Queries return rows as Maps.

Example

CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0 );

Understand it

A table is like a spreadsheet: columns define fields, rows are individual records. SQL is the language for reading and writing them.

⚠️ Common Mistake Forgetting to increment the database version when changing the schema — old installations won't run migrations.
💡 Pro Tip Design your schema before coding: what tables, what columns, what relationships?
✅ Key takeaway: SQLite = structured, queryable local data for offline-capable apps.
sqflite package

Practice

Design a simple 'notes' table with id, title, body, and created_at columns.

Use SQL CREATE TABLE syntax.
✓ CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT, body TEXT, created_at TEXT);
Previous 21 / 30 Next
22

CRUD Operations

35 min

Create, Read, Update, Delete — the four fundamental operations for any data store. This lesson implements them in Flutter with SQLite.

What you'll learn

  • Implement Create
  • Implement Read
  • Implement Update and Delete

Concept

CRUD is the backbone of data-driven apps. Create inserts new records, Read fetches them, Update modifies existing ones, and Delete removes them. In SQLite, these map to INSERT, SELECT, UPDATE, and DELETE commands.

How it works

Using sqflite, you call db.insert for create, db.query for read, db.update for update, and db.delete for remove. Each operation takes the table name and relevant values or conditions.

Example

// Create await db.insert('tasks', {'title': 'New task', 'done': 0}); // Read final tasks = await db.query('tasks'); // Update await db.update('tasks', {'done': 1}, where: 'id = ?', whereArgs: [1]); // Delete await db.delete('tasks', where: 'id = ?', whereArgs: [1]);

Understand it

Create and Read use no where clause (get/insert all). Update and Delete use a where clause to target a specific record by id.

⚠️ Common Mistake Deleting or updating without a where clause — it affects every row in the table.
💡 Pro Tip Always target by id when updating or deleting; it's the safest and most predictable approach.
✅ Key takeaway: CRUD = insert, query, update, delete — the four data operations every app needs.
sqflite CRUD guide

Practice

Write code to add, fetch, update, and delete a record in a tasks table.

Use db.insert, db.query, db.update, db.delete.
✓ See the example — insert with map, query all, update with where, delete with where.
Previous 22 / 30 Next
23

Image Loading & Caching

30 min

Images make apps feel alive, but loading them inefficiently causes lag. This lesson covers displaying and caching images.

What you'll learn

  • Load network images
  • Show placeholder while loading
  • Cache images for performance

Concept

Image.network loads an image from a URL but doesn't cache by default beyond a basic memory cache. The cached_network_image package adds disk caching, so images load instantly after the first time and work better offline.

How it works

Use Image.network for simple cases. For caching, use CachedNetworkImage with a placeholder and error widget. The package stores images on disk and serves them from cache on subsequent loads.

Example

CachedNetworkImage( imageUrl: 'https://example.com/image.jpg', placeholder: (context, url) => const CircularProgressIndicator(), errorWidget: (context, url, error) => const Icon(Icons.error), )

Understand it

The placeholder shows while downloading, and the errorWidget handles failures. The cached version loads instantly the next time.

⚠️ Common Mistake Loading full-size images into small list thumbnails — it wastes bandwidth and memory.
💡 Pro Tip Resize images on the server or use thumbnail URLs for lists; load full images only for detail views.
✅ Key takeaway: Cached images = faster loads, less bandwidth, better offline experience.
cached_network_image package

Practice

Replace an Image.network with a cached image and add a placeholder.

Use CachedNetworkImage with placeholder.
✓ CachedNetworkImage(imageUrl: url, placeholder: (c,u) => CircularProgressIndicator())
Previous 23 / 30 Next
24

Image Picker & File Handling

30 min

Most apps need to pick images from the gallery or camera. This lesson uses the image_picker plugin for user-selected media.

What you'll learn

  • Add image_picker
  • Pick images from gallery
  • Handle picked files

Concept

image_picker opens the device's gallery or camera and returns a file path (or bytes) for the selected media. You can then display it locally or upload it to a server. Permissions are handled by the plugin on most platforms.

How it works

Call ImagePicker().pickImage(source: ImageSource.gallery). It returns an XFile? (null if the user cancels). Use the file's path with File or Image.file to display it.

Example

final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); if (image != null) { // Display or upload image.path }

Understand it

The result is nullable because the user can cancel. Always check for null before using the file.

⚠️ Common Mistake Assuming the user always picks an image — handle the null (cancelled) case gracefully.
💡 Pro Tip On iOS, add photo library usage descriptions to Info.plist or the picker will crash.
✅ Key takeaway: image_picker = gallery/camera access with a simple, nullable result.
image_picker package

Practice

Add a button that opens the gallery and displays the chosen image.

Use ImagePicker().pickImage and Image.file.
✓ final img = await picker.pickImage(source: gallery); if (img != null) setState(() => _path = img.path);
Previous 24 / 30 Next
25

Camera & Device Media

35 min

Beyond picking images, apps can use the camera directly for live preview and custom capture experiences.

What you'll learn

  • Access the device camera
  • Capture photos
  • Integrate with media workflows

Concept

The camera plugin gives low-level access to the device camera — preview, capture, and flash control. image_picker uses the camera indirectly through the system UI, but the camera plugin lets you build a custom camera screen inside your app.

How it works

Initialize a controller from available cameras, show a CameraPreview, and call takePicture() to capture. Handle permissions and lifecycle carefully. For most apps, image_picker is simpler; use camera only when you need custom controls.

Example

final cameras = await availableCameras(); final camera = cameras.first; final controller = CameraController(camera, ResolutionPreset.medium); await controller.initialize(); // show CameraPreview(controller)

Understand it

The camera plugin gives you full control but requires more setup. image_picker is the easier path for simple capture.

⚠️ Common Mistake Forgetting to dispose the CameraController, which keeps the camera running and drains battery.
💡 Pro Tip Use image_picker for most cases; reach for camera only when you need in-app preview or custom UI.
✅ Key takeaway: Camera plugin = full control; image_picker = simple capture. Choose by need.
camera package

Practice

List the setup steps needed before using the camera plugin.

Think: permissions, initialization, preview, dispose.
✓ Request permission, list cameras, create controller, initialize, show preview, dispose after use.
Previous 25 / 30 Next
26

Permissions

30 min

Sensitive device features — camera, location, storage — require user permission. This lesson teaches how to request and handle them.

What you'll learn

  • Understand runtime permissions
  • Request permissions properly
  • Handle denial gracefully

Concept

Modern mobile OSes require apps to ask permission before accessing sensitive features. Android and iOS have different permission models. The permission_handler plugin provides a unified API to check and request permissions across both platforms.

How it works

Check the current status with Permission.camera.status. If not granted, call Permission.camera.request(). The result tells you whether the user granted, denied, or permanently denied — show an appropriate message for each.

Example

final status = await Permission.camera.request(); if (status.isGranted) { // use camera } else if (status.isPermanentlyDenied) { // guide user to settings }

Understand it

Permissions are runtime, not install-time. Never assume a permission is granted — always check and handle the denial case.

⚠️ Common Mistake Crashing because a permission was denied and the code assumed it was granted.
💡 Pro Tip Add permission entries to AndroidManifest.xml and Info.plist; missing entries cause silent failures.
✅ Key takeaway: Permissions = ask, check, and handle both grant and denial paths.
permission_handler package

Practice

Write logic to request camera permission and show a message if denied.

Use Permission.camera.request and check isGranted.
✓ final s = await Permission.camera.request(); if (!s.isGranted) showMessage('Permission denied');
Previous 26 / 30 Next
27

Search, Filter & Sorting

30 min

Users expect to find data quickly. This lesson teaches search, filtering, and sorting of local and API data.

What you'll learn

  • Implement search
  • Filter collections
  • Sort data

Concept

Search filters a list by a query string. Filtering narrows data by criteria like category or status. Sorting orders data by a field like date or name. Combined, they make data exploration fast and intuitive.

How it works

Keep the full data list in memory and a separate filtered list. Use where() to filter, sort() to order, and update state on every query change.

Example

final query = 'fl'; final filtered = items.where((item) => item.title.toLowerCase().contains(query.toLowerCase())).toList(); filtered.sort((a, b) => a.title.compareTo(b.title));

Understand it

where returns a new list with only matching items. sort reorders in place. Both are pure Dart operations — fast and simple.

⚠️ Common Mistake Mutating the original list while filtering — always filter into a separate list and preserve the source data.
💡 Pro Tip Lowercase both the query and the data before comparing for case-insensitive search.
✅ Key takeaway: Search = filter + sort over a copy of your data, preserving the original.
Dart List.where docs

Practice

Given a list of names, filter to those starting with 'A' and sort alphabetically.

Use where and sort.
✓ names.where((n) => n.startsWith('A')).toList()..sort();
Previous 27 / 30 Next
28

Pagination & Infinite Scrolling

30 min

Loading 10,000 items at once is slow. Pagination loads data in pages, and infinite scrolling fetches more as the user reaches the bottom.

What you'll learn

  • Understand pagination
  • Implement infinite scroll
  • Load more data on scroll

Concept

Pagination splits a large dataset into pages — the API returns 20 items at a time with a page number or cursor. Infinite scrolling detects when the user reaches the bottom of a list and loads the next page automatically.

How it works

Use a ScrollController and listen for when the scroll position reaches near the maximum. Trigger a fetch for the next page, append the new items to the existing list, and call setState. Track the current page and whether more data exists.

Example

_scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { _loadMore(); } });

Understand it

You detect the bottom by comparing current scroll position to maxScrollExtent. Loading ahead by a margin (200px) makes the transition feel seamless.

⚠️ Common Mistake Triggering _loadMore repeatedly while already loading — add an _isLoading flag to prevent duplicate requests.
💡 Pro Tip Add a loading spinner at the bottom of the list while the next page loads.
✅ Key takeaway: Pagination = fetch pages; infinite scroll = auto-load next page at the bottom.
Long lists in Flutter

Practice

Add infinite scroll to a list that fetches 20 more items when the user reaches the bottom.

Use a ScrollController and an _isLoading guard.
✓ Listen on ScrollController; when near maxScrollExtent and not loading, fetch next page and append.
Previous 28 / 30 Next
29

App Architecture — MVC / MVVM

35 min

As apps grow, unorganized code becomes impossible to maintain. Architecture patterns separate concerns for clean, testable code.

What you'll learn

  • Understand MVC pattern
  • Understand MVVM pattern
  • Choose a scalable structure

Concept

MVC (Model-View-Controller) separates data (Model), UI (View), and logic (Controller). MVVM (Model-View-ViewModel) binds the View to a ViewModel that exposes state and commands. Both keep business logic out of widgets, making code easier to test and maintain.

How it works

In Flutter, the View is your widgets. The Model is your data classes. The Controller/ViewModel holds business logic and exposes data to the View. State management tools like Provider or Riverpod act as the binding layer.

Example

MVVM structure: lib/ models/ # data classes viewmodels/ # logic + state views/ # widgets/screens services/ # API, database

Understand it

Separating concerns means you can change the UI without touching business logic, and test logic without rendering widgets.

⚠️ Common Mistake Putting API calls, state, and UI all inside one giant widget file — impossible to maintain as the app grows.
💡 Pro Tip Start simple; introduce a folder structure once you have more than 5-10 screens.
✅ Key takeaway: Architecture = clean separation of data, logic, and UI.
Flutter app architecture

Practice

Organize a simple app's files into models, views, and services folders.

Separate data classes, screens, and API/database code.
✓ Create lib/models, lib/views, lib/services and move code accordingly.
Previous 29 / 30 Next
30

Mini Project — Weather / News App

45 min

Combine everything: fetch from an API, parse JSON into models, handle states, and display in a responsive list. This is your Intermediate capstone.

What you'll learn

  • Integrate API + JSON + state
  • Build a real multi-screen app
  • Apply loading/error/empty states

Concept

A weather or news app pulls real data from a public API, parses it into typed models, handles loading and errors, and displays the results in a list or detail view. It uses nearly every concept from the Intermediate module.

How it works

Define models, write an API service with http, manage state with Provider/Riverpod, then build screens that watch the state and render lists with FutureBuilder or state management. Add a detail screen for individual items.

Example

A news app: fetch headlines from an API → parse into Article models → show a ListView of titles and images → tap an article to open its detail screen.

Understand it

This is the pattern for nearly every data-driven app: model + service + state + UI. Master it and you can build almost anything.

⚠️ Common Mistake Trying to build everything in one file — separate models, services, and screens from the start.
💡 Pro Tip Use a free public API like OpenWeatherMap or NewsAPI; they have clear docs and JSON responses.
✅ Key takeaway: Your intermediate capstone = API + JSON + state + screens, working together.
Flutter networking

Practice

Fetch data from a public API and display it in a list with loading and error states.

Create a model, service, and screen with FutureBuilder.
✓ model.fromJson + http.get + FutureBuilder handling waiting/error/data.
Previous 30 / 30
You have completed all 30 intermediate lessons.

Move on to Advanced for production-ready apps, security, and publishing.

Continue to Advanced

📱 Scan this QR code with your phone camera to instantly open this page.

Works on iOS, Android, and any modern device. No app installation required.

Account Verified!

Your email has been verified successfully.