March 27, 20269 min read

Flutter Tutorial: Build a Cross-Platform App That Doesn't Feel Like a Compromise

Learn Flutter from scratch. Dart basics, widgets, layouts, navigation, state management with Provider, HTTP requests, and building a weather app.

flutter dart mobile cross-platform tutorial
Ad 336x280

Cross-platform frameworks have a reputation problem. The apps feel a little off. Animations stutter. Platform conventions get ignored. Flutter is Google's answer to that, and it's genuinely different. Instead of wrapping native components or running a web view, Flutter draws every pixel itself using its own rendering engine. The result is consistent, smooth, and fast on both iOS and Android.

The trade-off is learning Dart, a language that feels like TypeScript and Java had a baby. But if you've used any C-style language, you'll pick it up in an afternoon.

Installing Flutter

Head to flutter.dev and follow the installation guide for your OS. Then verify:

flutter doctor

This checks your environment. You need the Flutter SDK and either Android Studio (for Android) or Xcode (for iOS). For just getting started, Android Studio alone is enough.

Create a new project:

flutter create weather_app
cd weather_app
flutter run

If you have an emulator running or a device connected, Flutter launches the default counter app.

Dart in Five Minutes

You don't need to master Dart before writing Flutter. Here's what matters:

// Variables: type-inferred or explicit
var name = 'Flutter';       // Inferred as String
String language = 'Dart';   // Explicit
final pi = 3.14;            // Cannot be reassigned
const gravity = 9.8;        // Compile-time constant

// Null safety: variables are non-nullable by default
String? maybeNull; // ? makes it nullable
String definitelyNotNull = 'hello';

// Functions
double celsiusToFahrenheit(double celsius) {
return celsius * 9 / 5 + 32;
}

// Arrow syntax for single expressions
double square(double x) => x * x;

// Classes
class City {
final String name;
final double latitude;
final double longitude;

City({required this.name, required this.latitude, required this.longitude});

@override
String toString() => '$name ($latitude, $longitude)';
}

// Using it
final london = City(name: 'London', latitude: 51.5, longitude: -0.12);

// Async/await (same as JavaScript)
Future<String> fetchData() async {
final response = await http.get(Uri.parse('https://api.example.com/data'));
return response.body;
}

// Collections
final numbers = [1, 2, 3, 4, 5];
final doubled = numbers.map((n) => n * 2).toList();
final evens = numbers.where((n) => n % 2 == 0).toList();

The biggest adjustment from JavaScript: Dart is strongly typed, null-safe, and has real classes with constructors. Everything else feels familiar.

The Widget Tree

In Flutter, everything is a widget. The entire UI is a tree of widgets. A button is a widget. Padding is a widget. A color is... you guessed it, a widget.

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

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

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Weather App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}

MaterialApp is the root widget that provides Material Design styling, navigation, and theming. Everything is nested inside it.

StatelessWidget vs StatefulWidget

This is the most important distinction in Flutter.

StatelessWidget: Displays data but doesn't change. It's built once (or rebuilt when its parent rebuilds). Use it for static UI.
class WeatherInfo extends StatelessWidget {
  final String city;
  final double temperature;

const WeatherInfo({super.key, required this.city, required this.temperature});

@override
Widget build(BuildContext context) {
return Column(
children: [
Text(city, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
Text('${temperature.round()}C', style: const TextStyle(fontSize: 64)),
],
);
}
}

StatefulWidget: Has mutable state. When the state changes, Flutter rebuilds the widget.
class TemperatureToggle extends StatefulWidget {
  final double celsius;

const TemperatureToggle({super.key, required this.celsius});

@override
State<TemperatureToggle> createState() => _TemperatureToggleState();
}

class _TemperatureToggleState extends State<TemperatureToggle> {
bool showFahrenheit = false;

@override
Widget build(BuildContext context) {
final display = showFahrenheit
? '${(widget.celsius * 9 / 5 + 32).round()}F'
: '${widget.celsius.round()}C';

return GestureDetector(
onTap: () {
setState(() {
showFahrenheit = !showFahrenheit;
});
},
child: Text(
display,
style: const TextStyle(fontSize: 64, fontWeight: FontWeight.w300),
),
);
}
}

setState() is the key. It tells Flutter: "Something changed, rebuild this widget." Never mutate state without calling setState().

Layouts: Row, Column, and Stack

Flutter has three primary layout widgets:

Column arranges children vertically:
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text('London'),
    Text('18C'),
    Text('Partly Cloudy'),
  ],
)
Row arranges children horizontally:
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Text('Humidity'),
    Text('72%'),
  ],
)
Stack overlaps children (like CSS position: absolute):
Stack(
  children: [
    Image.network(backgroundUrl, fit: BoxFit.cover),
    Positioned(
      bottom: 20,
      left: 20,
      child: Text('London', style: TextStyle(color: Colors.white, fontSize: 32)),
    ),
  ],
)

For spacing and sizing, you'll use Padding, SizedBox, Expanded, and Flexible:

Column(
  children: [
    Text('Current Weather'),
    const SizedBox(height: 16),  // Spacing
    Expanded(                     // Takes remaining space
      child: WeatherDetails(),
    ),
    Padding(
      padding: const EdgeInsets.all(16),
      child: Text('Last updated: 5 min ago'),
    ),
  ],
)

Building the Weather App

Let's put it together. We'll build an app that shows current weather for a city using the OpenWeatherMap API.

The Data Model

// lib/models/weather.dart
class Weather {
  final String city;
  final double temperature;
  final String description;
  final String icon;
  final int humidity;
  final double windSpeed;

Weather({
required this.city,
required this.temperature,
required this.description,
required this.icon,
required this.humidity,
required this.windSpeed,
});

factory Weather.fromJson(Map<String, dynamic> json) {
return Weather(
city: json['name'],
temperature: (json['main']['temp'] as num).toDouble(),
description: json['weather'][0]['description'],
icon: json['weather'][0]['icon'],
humidity: json['main']['humidity'],
windSpeed: (json['wind']['speed'] as num).toDouble(),
);
}
}

The API Service

Add the http package to pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0
  provider: ^6.1.0

Run flutter pub get, then:

// lib/services/weather_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/weather.dart';

class WeatherService {
static const _apiKey = 'YOUR_API_KEY'; // Get from openweathermap.org
static const _baseUrl = 'https://api.openweathermap.org/data/2.5';

Future<Weather> getWeather(String city) async {
final url = Uri.parse(
'$_baseUrl/weather?q=$city&appid=$_apiKey&units=metric',
);

final response = await http.get(url);

if (response.statusCode == 200) {
return Weather.fromJson(jsonDecode(response.body));
} else if (response.statusCode == 404) {
throw Exception('City not found');
} else {
throw Exception('Failed to load weather data');
}
}
}

State Management with Provider

Provider is the recommended starting point for state management in Flutter. It's simple and works well for most apps.

// lib/providers/weather_provider.dart
import 'package:flutter/material.dart';
import '../models/weather.dart';
import '../services/weather_service.dart';

class WeatherProvider extends ChangeNotifier {
final WeatherService _service = WeatherService();

Weather? _weather;
bool _loading = false;
String? _error;

Weather? get weather => _weather;
bool get loading => _loading;
String? get error => _error;

Future<void> fetchWeather(String city) async {
_loading = true;
_error = null;
notifyListeners();

try {
_weather = await _service.getWeather(city);
} catch (e) {
_error = e.toString();
}

_loading = false;
notifyListeners();
}
}

notifyListeners() is Provider's equivalent of setState() -- it tells all listening widgets to rebuild.

The Home Screen

// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/weather_provider.dart';

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

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
final _controller = TextEditingController();

@override
void initState() {
super.initState();
// Fetch default city on load
Future.microtask(() {
context.read<WeatherProvider>().fetchWeather('London');
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Weather'),
centerTitle: true,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Enter city name',
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: () {
if (_controller.text.isNotEmpty) {
context.read<WeatherProvider>().fetchWeather(_controller.text);
}
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (value) {
if (value.isNotEmpty) {
context.read<WeatherProvider>().fetchWeather(value);
}
},
),
),
Expanded(
child: Consumer<WeatherProvider>(
builder: (context, provider, child) {
if (provider.loading) {
return const Center(child: CircularProgressIndicator());
}

if (provider.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(provider.error!, style: const TextStyle(fontSize: 16)),
],
),
);
}

final weather = provider.weather;
if (weather == null) return const SizedBox.shrink();

return WeatherDisplay(weather: weather);
},
),
),
],
),
);
}
}

class WeatherDisplay extends StatelessWidget {
final Weather weather;

const WeatherDisplay({super.key, required this.weather});

@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 32),
Text(
weather.city,
style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Image.network(
'https://openweathermap.org/img/wn/${weather.icon}@4x.png',
width: 120,
height: 120,
),
Text(
'${weather.temperature.round()}C',
style: const TextStyle(fontSize: 72, fontWeight: FontWeight.w200),
),
Text(
weather.description,
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildInfoCard(Icons.water_drop, 'Humidity', '${weather.humidity}%'),
_buildInfoCard(Icons.air, 'Wind', '${weather.windSpeed} m/s'),
],
),
],
),
);
}

Widget _buildInfoCard(IconData icon, String label, String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(icon, color: Colors.blue, size: 28),
const SizedBox(height: 8),
Text(label, style: TextStyle(color: Colors.grey[600])),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
);
}
}

Wiring It Up

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/weather_provider.dart';
import 'screens/home_screen.dart';

void main() {
runApp(const MyApp());
}

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

@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => WeatherProvider(),
child: MaterialApp(
title: 'Weather App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
),
);
}
}

Flutter's navigation works with a stack, like a deck of cards. You push screens on top and pop them off:

// Navigate to a new screen
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const DetailScreen()),
);

// Go back
Navigator.pop(context);

// Replace the current screen
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);

For named routes (better for larger apps):

MaterialApp(
  routes: {
    '/': (context) => const HomeScreen(),
    '/detail': (context) => const DetailScreen(),
    '/settings': (context) => const SettingsScreen(),
  },
);

// Navigate
Navigator.pushNamed(context, '/detail');

For production apps, consider the go_router package which provides declarative, URL-based routing similar to web frameworks.

Common Mistakes

Widget nesting hell. Flutter code can get deeply nested. Extract widgets into separate classes and methods. If your build method is more than 50 lines, break it up. Using StatefulWidget everywhere. Not everything needs local state. If data comes from a parent or a provider, use StatelessWidget. It's simpler and more performant. Forgetting const constructors. Dart's const tells Flutter that a widget will never change, so it can skip rebuilding it. Use const wherever possible:
// Good
const SizedBox(height: 16)
const Text('Hello')

// Bad (unnecessary rebuild every time)
SizedBox(height: 16)
Text('Hello')

Not handling async errors. Every Future needs error handling. Unhandled exceptions in async code silently disappear or crash the app. Ignoring the dispose method. If you create controllers, streams, or animation controllers in a StatefulWidget, clean them up:
@override
void dispose() {
  _controller.dispose();
  super.dispose();
}
Hardcoding dimensions. Use MediaQuery.of(context).size for responsive layouts, not fixed pixel values. What fits on a Pixel 7 won't fit on an iPhone SE.

What's Next

You've built a weather app with API integration, state management, and a clean architecture. From here, explore animations with Flutter's built-in animation system, local storage with shared_preferences or hive, more advanced state management with Riverpod or BLoC, and platform-specific features like camera access and geolocation.

For more Flutter projects and structured learning paths, check out CodeUp.

Ad 728x90