πŸ’»Samir's late night thoughts

Flutter Basics: Building Cross-Platform Apps with Dart 3

Intro: What Is Flutter?

Flutter is Google's open-source UI toolkit for building natively compiled apps for mobile, web, and desktop from a single Dart codebase. Unlike React Native, Flutter does not use native platform widgets β€” it draws every pixel itself using its own rendering engine (Impeller as of 2026), giving you pixel-perfect consistency across iOS and Android.

As of 2026, Flutter 3.27+ ships with Dart 3, Material 3 on by default, and Impeller as the default renderer on both platforms.


Step 1: Setting Up a New Project

Install the Flutter SDK, then create a new project:

flutter create my_app --platforms=ios,android
cd my_app
flutter run

Your pubspec.yaml key dependencies:

environment:
  sdk: ">=3.6.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1
  go_router: ^14.0.0
  dio: ^5.7.0
  freezed_annotation: ^2.4.0
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^2.5.0
  json_serializable: ^6.8.0
  riverpod_generator: ^2.6.1

Enable Material 3 in your app theme (it is the default, but shown here explicitly):

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'router.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Tech Blog',
      routerConfig: appRouter,
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      themeMode: ThemeMode.system,
    );
  }
}

Step 2: Widgets β€” The Building Blocks

Everything in Flutter is a widget. Widgets are immutable descriptions of part of the UI β€” Flutter rebuilds the widget tree efficiently whenever state changes.

StatelessWidget β€” no mutable state

import 'package:flutter/material.dart';

class GreetingCard extends StatelessWidget {
  const GreetingCard({super.key, required this.name});

  final String name;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Hello, $name!', style: theme.textTheme.headlineSmall),
            const SizedBox(height: 4),
            Text('Welcome back.', style: theme.textTheme.bodyMedium),
          ],
        ),
      ),
    );
  }
}

StatefulWidget β€” owns mutable state

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(
          '$_count',
          style: Theme.of(context).textTheme.displayLarge,
        ),
        const SizedBox(height: 24),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            OutlinedButton(
              onPressed: () => setState(() => _count--),
              child: const Text('βˆ’'),
            ),
            const SizedBox(width: 16),
            FilledButton(
              onPressed: () => setState(() => _count++),
              child: const Text('+'),
            ),
          ],
        ),
        TextButton(
          onPressed: () => setState(() => _count = 0),
          child: const Text('Reset'),
        ),
      ],
    );
  }
}

Step 3: Layouts β€” Column, Row, and Stack

Flutter's layout widgets mirror what you'd expect from Jetpack Compose or SwiftUI.

Column and Row

class ProfileHeader extends StatelessWidget {
  const ProfileHeader({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        const CircleAvatar(radius: 32, backgroundImage: NetworkImage('https://picsum.photos/64')),
        const SizedBox(width: 16),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('Jane Doe', style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 4),
              Text('Flutter Developer', style: Theme.of(context).textTheme.bodySmall),
            ],
          ),
        ),
        IconButton(
          icon: const Icon(Icons.more_vert),
          onPressed: () {},
        ),
      ],
    );
  }
}

Stack β€” overlapping widgets

class BadgedAvatar extends StatelessWidget {
  const BadgedAvatar({super.key, required this.badgeCount});

  final int badgeCount;

  @override
  Widget build(BuildContext context) {
    return Stack(
      clipBehavior: Clip.none,
      children: [
        const CircleAvatar(radius: 28, backgroundImage: NetworkImage('https://picsum.photos/56')),
        if (badgeCount > 0)
          Positioned(
            top: -4,
            right: -4,
            child: Container(
              padding: const EdgeInsets.all(4),
              decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
              child: Text('$badgeCount', style: const TextStyle(color: Colors.white, fontSize: 10)),
            ),
          ),
      ],
    );
  }
}

Expanded and Flexible

Row(
  children: [
    // Takes up 2/3 of available width
    Expanded(flex: 2, child: TextField(decoration: InputDecoration(hintText: 'Search…'))),
    const SizedBox(width: 8),
    // Takes up 1/3
    Expanded(child: FilledButton(onPressed: () {}, child: const Text('Go'))),
  ],
)

Step 4: Decoration and Styling

Use Container with a BoxDecoration for custom backgrounds, borders, and shadows.

class StyledCard extends StatelessWidget {
  const StyledCard({super.key, required this.label});

  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.08),
            blurRadius: 12,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Text(label, style: Theme.of(context).textTheme.titleMedium),
    );
  }
}

Dart 3.6 note: Use .withValues(alpha:) instead of the deprecated .withOpacity() for color alpha adjustments.


Step 5: State Management with Riverpod 2

Riverpod is the recommended state management solution for Flutter in 2026. Use the code-generator approach with @riverpod annotations.

Simple state provider

// lib/providers/counter_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'counter_provider.g.dart';

@riverpod
class CounterNotifier extends _$CounterNotifier {
  @override
  int build() => 0;

  void increment() => state++;
  void decrement() => state--;
  void reset() => state = 0;
}

Run the generator once (or in watch mode during development):

dart run build_runner watch

Consuming the provider

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Extend ConsumerWidget instead of StatelessWidget
class CounterScreen extends ConsumerWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('$count', style: Theme.of(context).textTheme.displayLarge),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                OutlinedButton(
                  onPressed: () => ref.read(counterNotifierProvider.notifier).decrement(),
                  child: const Text('βˆ’'),
                ),
                const SizedBox(width: 16),
                FilledButton(
                  onPressed: () => ref.read(counterNotifierProvider.notifier).increment(),
                  child: const Text('+'),
                ),
              ],
            ),
            TextButton(
              onPressed: () => ref.read(counterNotifierProvider.notifier).reset(),
              child: const Text('Reset'),
            ),
          ],
        ),
      ),
    );
  }
}

Step 6: Data Models with Freezed

Use freezed for immutable, copyable data classes with generated ==, hashCode, and copyWith.

// lib/models/post.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'post.freezed.dart';
part 'post.g.dart';

@freezed
class Post with _$Post {
  const factory Post({
    required int id,
    required int userId,
    required String title,
    required String body,
  }) = _Post;

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}

Step 7: Async Data Fetching with Riverpod

Riverpod's AsyncNotifier handles loading, error, and data states automatically.

// lib/providers/posts_provider.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/post.dart';

part 'posts_provider.g.dart';

final _dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));

@riverpod
Future<List<Post>> posts(Ref ref) async {
  final response = await _dio.get<List>('/posts');
  return response.data!.map((e) => Post.fromJson(e as Map<String, dynamic>)).toList();
}

Consume the async provider β€” AsyncValue handles all three states:

class PostListScreen extends ConsumerWidget {
  const PostListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final postsAsync = ref.watch(postsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: postsAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => Center(child: Text('Error: $error')),
        data: (posts) => ListView.builder(
          itemCount: posts.length,
          itemBuilder: (context, index) => PostTile(post: posts[index]),
        ),
      ),
    );
  }
}

Step 8: Lists with ListView.builder

class PostTile extends StatelessWidget {
  const PostTile({super.key, required this.post});

  final Post post;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: ListTile(
        contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        title: Text(
          post.title,
          style: Theme.of(context).textTheme.titleSmall,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
        subtitle: Padding(
          padding: const EdgeInsets.only(top: 4),
          child: Text(
            post.body,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ),
        trailing: const Icon(Icons.chevron_right),
        onTap: () {/* navigate */},
      ),
    );
  }
}

For grids, use GridView.builder:

GridView.builder(
  padding: const EdgeInsets.all(16),
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
    childAspectRatio: 3 / 4,
  ),
  itemCount: posts.length,
  itemBuilder: (context, index) => PostCard(post: posts[index]),
)

Step 9: Navigation with GoRouter

GoRouter is Flutter's officially recommended router, supporting deep links, redirects, and typed routes.

// lib/router.dart
import 'package:go_router/go_router.dart';
import 'screens/home_screen.dart';
import 'screens/post_detail_screen.dart';
import 'screens/settings_screen.dart';

final appRouter = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'posts/:id',
          builder: (context, state) {
            final id = int.parse(state.pathParameters['id']!);
            return PostDetailScreen(postId: id);
          },
        ),
      ],
    ),
    GoRoute(
      path: '/settings',
      builder: (context, state) => const SettingsScreen(),
    ),
  ],
);

Navigate from anywhere:

// Push a new route
context.push('/posts/42');

// Replace current route
context.go('/settings');

// Go back
context.pop();

Pass the router to MaterialApp.router:

MaterialApp.router(
  routerConfig: appRouter,
  // ...
)

Step 10: Dart 3 Features Worth Knowing

Dart 3 introduced several powerful language features that appear throughout modern Flutter codebases.

Records

// Return multiple values without a class
(String, int) getUserInfo() => ('Jane', 28);

final (name, age) = getUserInfo();
print('$name is $age'); // Jane is 28

Pattern Matching and Switch Expressions

sealed class NetworkState {}
class Loading extends NetworkState {}
class Success<T> extends NetworkState { final T data; Success(this.data); }
class Failure extends NetworkState { final String message; Failure(this.message); }

Widget buildFromState(NetworkState state) => switch (state) {
  Loading()        => const CircularProgressIndicator(),
  Success(:final data) => Text('$data'),
  Failure(:final message) => Text('Error: $message', style: const TextStyle(color: Colors.red)),
};

Null-aware and collection operators

final List<String> tags = ['flutter', 'dart', null, 'mobile']
    .whereType<String>()           // filter out nulls
    .map((t) => t.toUpperCase())
    .toList();

final merged = [...listA, ...?maybeListB]; // spread with null check

Quick Reference

ConceptAPI
No-state widgetclass Foo extends StatelessWidget
Local mutable stateclass Foo extends StatefulWidget + setState()
Shared state@riverpod + ConsumerWidget + ref.watch()
Async data@riverpod Future<T> + .when(loading, error, data)
Vertical layoutColumn(children: [...])
Horizontal layoutRow(children: [...])
Overlapping layersStack(children: [...])
Fill remaining spaceExpanded(child: ...)
Scrollable listListView.builder(itemBuilder: ...)
GridGridView.builder(gridDelegate: ...)
Navigation setupGoRouter(routes: [...])
Push routecontext.push('/path')
Replace routecontext.go('/path')
Decoration / bordersContainer(decoration: BoxDecoration(...))
Multiple return valuesDart 3 records: (String, int)
Exhaustive branchingswitch (sealedClass) { case ...: }