Welcome to the Flutter Workshop, which is designed to guide you through enterprise Flutter development with hands-on learning and practical exercises. By the end of this workshop, you'll understand core Flutter patterns and be ready to (learn more about) build(ing) production-ready applications.
- Workshop Overview
- Prerequisites
- Part 1: Overview
- Part 2: Workshop Exercises
- Part 3: Congratulations!
- Where to Go From Here
- When to Choose Flutter
- Essential Flutter Commands
- Performance Best Practices
This workshop is structured as:
- Presentation
- Hands-on Exercises: 3 core exercises + 2 optional extensions
You'll work with a Climate Data visualization app that demonstrates Flutter patterns:
- MVVM Architecture with reactive state management
- Functional error handling using the 'Either' monad
- API integration with weather data
- Interactive charts and map visualization
Rather than building from scratch, you'll complete targeted features in an existing codebase. This mirrors real-world enterprise development where you'll often extend and maintain existing applications.
Before starting the workshop, ensure you have:
✅ Docker installed (run docker version to verify)
✅ IDE configured (VS Code with Dev Container extension)
✅ Web browser installed (Feel free to pick Firefox, Chrome, or something exotic and surprisisng)
✅ App successfully runs (run flutter run -d web-server)
Note: If you encounter setup issues, refer to the Official Flutter Installation Guide, or ask the referents of this workshop.
Flutter is Google's open-source UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase.
Key Advantages:
- Cross-platform: Write once, deploy to iOS, Android, Web, Windows, macOS, Linux
- Beautiful UI: Rich widget library with Material and Cupertino designs
- Hot Reload: See changes instantly (exceptions apply) without losing app state
- 💼 Enterprise-Ready: Used by Alibaba, BMW, Google Pay, eBay
Why Dart?
- Optimized for UI development with async/await support
- Ahead-of-time (AOT) compilation for production performance
- Just-in-time (JIT) compilation for fast development cycles
Learn More: What is Flutter?
Our Climate App follows Clean Architecture principles:
lib/
├── config/ # Dependency injection setup (watch_it)
├── core/ # Shared theme and styling
├── data/ # External concerns (API clients, repositories)
│ ├── repositories/
│ └── services/api/
├── domain/ # Business entities (Location model)
│ └── models/
├── ui/ # Presentation layer
│ └── climate/
│ ├── view_model/ # Business logic (ClimateViewModel)
│ └── widgets/ # UI components (Screens, Diagrams)
├── routing/ # Navigation configuration
└── web
├── favicon.png
├── icons/
├── index.html
└── manifest.json
Learn More: Flutter App Architecture
1. MVVM (Model-View-ViewModel)
ClimateScreen (View) → watches → ClimateViewModel → uses → ClimateRepository
↓
notifyListeners()
↓
UI rebuilds
Benefits:
- Separation of concerns: UI doesn't know about API details
- Testable business logic without UI dependencies
- Reusable ViewModels across different screens
2. Reactive State Management (watch_it)
final selectedLocation = watchPropertyValue(
(ClimateViewModel vm) => vm.selectedLocation,
);When the ViewModel calls notifyListeners(), only widgets watching that specific property rebuild - not the entire screen.
Learn More: State Management Approaches
3. Functional Error Handling (Either Monad)
Either<String, WeatherApiModel> // Left = error, Right = successThis pattern makes errors explicit and type-safe - no hidden exceptions for expected failures.
Learn More: Error Handling Best Practices
4. Dependency Injection (Service Locator)
// Registration (dependencies.dart)
sl.registerLazySingleton<ClimateViewModel>(
() => ClimateViewModel(climateRepository: sl()),
);
// Usage
sl<ClimateViewModel>().selectLocation('Berlin');Benefits: Loose coupling, easier testing, centralized configuration.
Each exercise teaches a core Flutter pattern by implementing a missing feature:
| Exercise | Difficulty | Concepts |
|---|---|---|
| 1. Location Selection | ** | State updates, side effects, Map data structures |
| 2. Error Handling | *** | Either monad, pattern matching, error states |
| 3. Conditional UI | *** | Ternary operators, null safety, loading states |
| 4. Input Validation | ** | Form validation, user feedback, SnackBars |
| 5. Data Scaling | **** | List operations, mathematical transformations |
Tip: Complete exercises in order. Each builds on concepts from the previous one.
File: lib/ui/climate/view_model/climate_view_model.dart
- ✅ Trigger state changes from user actions
- ✅ Understand the notifyListeners() pattern
- ✅ Work with Map data structures
- ✅ Chain side effects (fetch data after selection)
Users can select locations from the drawer menu. When a location is tapped, the app should:
- Update which location is currently selected
- Fetch climate data for that location from the API
- Notify all watching widgets to rebuild with new data
The Reactive Flow:
User taps "Berlin" → selectLocation("Berlin") →
_selectedLocation = "Berlin" → fetchClimateData() →
notifyListeners() → ClimateScreen rebuilds → Shows Berlin weather
Implement the selectLocation method that's currently a comment at line 47.
- ✅ Check if the location exists in the
_locationsMap - ✅ If valid:
- Update
_selectedLocationto the new location name - Call
fetchClimateData()to load data - Call
notifyListeners()to trigger UI rebuild
- Update
void selectLocation(String name) {
// TODO: Exercise 1
// 1. Check if location exists: _locations.containsKey(name)
// 2. Update _selectedLocation = name
// 3. Call fetchClimateData()
// 4. Call notifyListeners()
}✅ The location name in the AppBar changes
✅ The map marker moves to the new coordinates
- Map Operations: Use
_locations.containsKey(name)to safely check existence - State Updates:
notifyListeners()is the "magic" that triggers rebuilds - Side Effects:
fetchClimateData()is already implemented - just call it - Defensive Programming: Always validate before mutating state
Learn More: Managing State in Flutter
void selectLocation(String name) {
if (_locations.containsKey(name)) {
_selectedLocation = name;
fetchClimateData();
notifyListeners();
}
}Why this works:
containsKey()prevents crashes from invalid location names- Setting
_selectedLocationupdates the state fetchClimateData()is async but we don't await (fire-and-forget pattern)notifyListeners()broadcasts the state change to all listeners
File: lib/ui/climate/view_model/climate_view_model.dart
- ✅ Understand the Either monad for error handling
- ✅ Use pattern matching with
match() - ✅ Manage multiple state variables consistently
- ✅ Implement enterprise-grade error handling
API calls can fail for many reasons:
- Network connectivity issues
- Invalid coordinates
- Server errors (500, 503)
- Rate limiting
Instead of throwing exceptions, our app uses the Either type from functional programming:
Either<String, WeatherApiModel>
↓ ↓
Left Right
(Error) (Success)The match() method safely handles both cases without try-catch blocks.
Complete the error handling in the fetchClimateData() method at line 67.
When the API call fails (Left side of Either):
- Set
_errorMessageto the error string - Set
_climateDatatonull(clear stale data)
When the API call succeeds (Right side of Either):
- Set
_climateDatato the received weather data - Set
_errorMessagetonull(clear previous errors)
Important: Always set both state variables to maintain consistency.
result.match(
(error) {
// TODO: Exercise 2a - Handle error case
// Set _errorMessage = error
// Set _climateData = null
},
(data) {
// TODO: Exercise 2b - Handle success case
// Set _climateData = data
// Set _errorMessage = null
},
);✅ Invalid coordinates display an error message in the UI
✅ Valid coordinates show the climate chart
✅ Network errors show meaningful messages
✅ Error messages disappear when new successful data loads
✅ Old chart data clears when an error occurs
Test Case 1: Simulate API Error
Temporarily modify lib/data/services/api/api_client.dart at line 33:
// Replace this line:
return Either.right(weatherData);
// With this:
return Either.left('Test error: API unavailable');Select any location - you should see your error message.
Test Case 2: Invalid Coordinates
Add a location with invalid coordinates (e.g., latitude = 200).
- Pattern Matching: The
match()function takes two callbacks:(leftValue)and(rightValue) - State Consistency: Never have both
_errorMessageand_climateDataset simultaneously- Already Implemented:
notifyListeners()is called aftermatch()completes - Functional Style: No exceptions, no null checks - exhaustive pattern matching
- Already Implemented:
Learn More:
result.match(
(error) {
_errorMessage = error;
_climateData = null;
},
(data) {
_climateData = data;
_errorMessage = null;
},
);Why this pattern works:
- Type Safety: Compiler ensures both cases are handled
- Explicit Errors: No hidden exceptions - all errors are values
- Maintainability: Adding new error types doesn't break existing code
- Testability: Easy to mock Either<Error, Success> in unit tests
Enterprise Benefit: This pattern scales to complex apps where multiple error types (validation, network, business logic) need different handling strategies.
File: lib/ui/climate/widgets/climate_screen.dart
- ✅ Use ternary operators to conditionally render widgets
- ✅ Handle loading, error, success, and empty states
- ✅ Understand Flutter's declarative UI paradigm
- ✅ Implement proper state priority ordering
The app body needs to display different widgets based on four possible states:
| State | When | What to Show |
|---|---|---|
| Loading | isLoading == true |
Circular progress indicator |
| Error | errorMessage != null |
Error text |
| Success | climateData != null |
Climate chart with data |
| Empty | None of above | 'Select a location' placeholder |
The Declarative Approach:
In Flutter, UI = f(state). We describe what to show for each state, and Flutter handles the updates:
// NOT imperative: "If loading, show spinner, else hide it"
// YES declarative: "When loading, the child IS a spinner"
child: isLoading ? Spinner() : Chart()Implement the conditional rendering logic in _ClimateBody.build() starting at line 260.
Build the widget tree using nested ternary operators in this priority order:
- First check:
isLoading→ show loading spinner - Then check:
errorMessage != null→ show error text - Then check:
climateData != null→ show chart - Otherwise: show placeholder message
Critical: Order matters! Loading state has highest priority.
child: Center(
child: // TODO: Exercise 3
// Use nested ternary operators:
// isLoading ? LoadingWidget : (errorMessage != null ? ErrorWidget : ...)
// Widget templates:
// Loading: const CircularProgressIndicator(color: Colors.white)
// Error: Text(errorMessage, style: const TextStyle(color: Colors.white))
// Success: const ClimateDiagram()
// Empty: const Text('Select a location to view climate data',
// style: TextStyle(color: Colors.white70))
),✅ Loading spinner appears immediately when selecting a new location
✅ Error messages display in white text against the dark blue background
✅ Climate chart renders when data loads successfully
✅ Placeholder message shows on first app launch
✅ States transition smoothly (Loading → Success or Loading → Error)
Scenario 1: Normal Flow
- Launch app → See placeholder
- Select location → See spinner (brief)
- Data loads → See chart
Scenario 2: Error Flow
- Add invalid location → See spinner (brief)
- API fails → See error message
- Select valid location → See spinner → See chart (error cleared)
- Ternary Syntax:
condition ? trueWidget : falseWidget- Nesting Ternaries: Chain them with proper indentation for readability
- Const Constructors: Use
constwhere possible for performance (compile-time optimization) - State Priority: Check most important state first (loading blocks everything else)
Learn More:
child: Center(
child: isLoading
? const CircularProgressIndicator(color: Colors.white)
: errorMessage != null
? Text(
errorMessage,
style: const TextStyle(color: Colors.white),
)
: climateData != null
? const ClimateDiagram()
: const Text(
'Select a location to view climate data',
style: TextStyle(color: Colors.white70),
),
),Why this structure works:
Priority Ordering:
- Loading trumps everything (users should know something is happening)
- Errors take precedence over stale data
- Success only shows when no loading or errors
- Empty state is the final fallback
Performance Considerations:
constconstructors are created at compile-time (zero runtime cost)Centerwidget doesn't rebuild - only its child changes- Conditional rendering prevents unnecessary widget creation
File: lib/ui/climate/widgets/climate_screen.dart
- ✅ Implement form input validation
- ✅ Provide user feedback with SnackBars
- ✅ Prevent invalid data from entering the system
- ✅ Handle edge cases gracefully
Currently, the "Add Location" dialog accepts any coordinate values. This causes problems:
- Latitude = 200 → API returns error
- Longitude = -500 → Invalid request
- Empty name → Silent failure
Geographic Coordinate Constraints:
- Latitude: -90° (South Pole) to +90° (North Pole)
- Longitude: -180° (West) to +180° (East)
Add validation to _showAddLocationDialog before calling addLocation() at lines starting at 118.
- Validate name is not empty (already implemented)
- Validate latitude:
-90 ≤ lat ≤ 90 - Validate longitude:
-180 ≤ lon ≤ 180 - Show SnackBar with error message if validation fails
- Only add location and close dialog if all validations pass
if (name.isNotEmpty && lat != null && lon != null) {
// TODO: Exercise 4 - Add coordinate validation
// Check: lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
// If valid: call addLocation and close dialog
// If invalid: show SnackBar with error message
sl<ClimateViewModel>().addLocation(name, lat, lon);
Navigator.pop(context);
}ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid coordinates. Latitude: -90 to 90, Longitude: -180 to 180'),
backgroundColor: Colors.red,
duration: Duration(seconds: 3),
),
);✅ Valid coordinates (e.g., Paris: 48.8566, 2.3522) are accepted
✅ Invalid latitude (e.g., 100) shows error, dialog stays open
✅ Invalid longitude (e.g., -200) shows error, dialog stays open
✅ Error message is clear and actionable
✅ Dialog only closes after successful validation
| Location | Latitude | Longitude | Expected Result |
|---|---|---|---|
| Tokyo | 35.6762 | 139.6503 | ✅ Success |
| Invalid 1 | 100 | 50 | ❌ Error: Latitude out of range |
| Invalid 2 | 45 | -200 | ❌ Error: Longitude out of range |
| North Pole | 90 | 0 | ✅ Success (boundary) |
| South Pole | -90 | 0 | ✅ Success (boundary) |
- Boundary Testing: Remember to allow exactly -90, 90, -180, 180
- User Experience: Keep the dialog open on error so users can correct their input
- Error Messages: Be specific about what's wrong and what values are valid
- Compound Conditions: Use
&&to combine multiple validation checks
Learn More:
if (name.isNotEmpty && lat != null && lon != null) {
String? errorMessage;
if (lat < -90 || lat > 90) {
errorMessage = 'Latitude must be between -90 and 90 degrees';
} else if (lon < -180 || lon > 180) {
errorMessage = 'Longitude must be between -180 and 180 degrees';
}
if (errorMessage == null) {
sl<ClimateViewModel>().addLocation(name, lat, lon);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red,
),
);
}
}Production Enhancement: In a real app, you'd also validate:
- Name doesn't contain special characters
- Location name doesn't already exist (prevent duplicates)
- Coordinates point to land (optional, using geocoding API)
File: lib/ui/climate/widgets/climate_diagram.dart
- ✅ Perform mathematical transformations on datasets
- ✅ Use functional programming with
reduce() - ✅ Handle edge cases (division by zero)
- ✅ Understand chart visualization scaling
The climate chart displays two metrics on the same Y-axis:
- Temperature: Ranges from -20°C to +40°C (60° range)
- Precipitation: Ranges from 0mm to 100mm (100mm range)
The Problem:
Without scaling, precipitation bars would be invisible compared to temperature:
Temperature: 25°C → Chart shows at 25 units
Precipitation: 25mm → Chart shows at 25 units (same height!)
But 25mm of rain is a LOT more significant than 25°C in this context.
The Solution:
Scale precipitation to use ~80% of the temperature range:
If temp range = 60° and max precip = 100mm:
Scale factor = (60 * 0.8) / 100 = 0.48
Precip value of 50mm displays as: 50 * 0.48 = 24 chart units
Calculate the scaling factor for precipitation data starting at line 21.
- Find the maximum precipitation value
- Find the temperature range (max temp - min temp)
- Calculate scale factor:
(tempRange * 0.8) / maxPrecipitation - Handle edge case: if max precipitation is 0, use scale factor of 1.0
Why 0.8? This reserves 20% of chart space above precipitation bars, preventing them from being too heavy on the visualization.
// TODO: Exercise 5
// 1. Find maxPrecipitation: precip.reduce(math.max)
// 2. Find maxTemperature: maxTemp.reduce(math.max)
// 3. Find minTemperature: minTemp.reduce(math.min)
// 4. Calculate tempRange = maxTemperature - minTemperature
// 5. Calculate precipScale:
// - If maxPrecipitation > 0: (tempRange * 0.8) / maxPrecipitation
// - Else: 1.0 (prevent division by zero)
final maxPrecipitation = 0.0; // Replace this
final precipScale = 1.0; // Replace this✅ Precipitation bars are visible and proportional on the chart
✅ Precipitation scales appropriately with temperature axis
✅ Right Y-axis labels show correct precipitation values in mm
✅ No crashes when precipitation is 0 (e.g., desert locations)
✅ Chart remains readable with varying data ranges
Example Calculation (Berlin):
- Max temperature: 35°C
- Min temperature: -5°C
- Temp range: 40°C
- Max precipitation: 80mm
precipScale = (40 * 0.8) / 80 = 0.4
// When displaying 60mm precipitation:
displayValue = 60 * 0.4 = 24°C equivalent on chart
// When showing tooltip (lines 178-209):
actualValue = displayValue / precipScale = 24 / 0.4 = 60mm ✅- List.reduce(): Applies a function across all list elements
- math.max/min: Dart's built-in comparison functions
- Division by Zero: Always check if denominator > 0 before dividing
- Ternary Operator: Perfect for conditional expressions
- Import Needed:
import 'dart:math' as math;(already present)
Learn More:
final maxPrecipitation = precip.reduce(math.max);
final maxTemperature = maxTemp.reduce(math.max);
final minTemperature = minTemp.reduce(math.min);
final tempRange = maxTemperature - minTemperature;
final precipScale = maxPrecipitation > 0
? (tempRange * 0.8) / maxPrecipitation
: 1.0;How It's Used in the Chart:
Displaying Precipitation:
LineChartBarData(
spots: List.generate(
precip.length,
(i) => FlSpot(i.toDouble(), precip[i] * precipScale), // Scaled up
),
// ... styling
),Tooltip Labels:
if (touchedSpot.barIndex == 0) {
label = 'Precipitation';
value = '${precip[index].toStringAsFixed(1)} mm'; // Original value
color = Colors.green;
}Right Y-Axis Labels:
getTitlesWidget: (value, meta) {
if (value < 0) return const Text('');
final precipValue = value / precipScale; // Scale back down
return Text('${precipValue.toInt()} mm', ...);
}Why This Works:
- Data is scaled UP for display on the chart
- Labels are scaled DOWN to show actual values
- Visual proportions match data significance
- Edge case (zero precipitation) is handled gracefully
Alternative Approach (Min-Max Normalization):
// Normalize both datasets to 0-1 range, then scale to chart
final tempNormalized = temp / tempRange;
final precipNormalized = precip / maxPrecip;This gives even more control but is overkill for this use case.
You've completed the Flutter Climate App workshop. Let's summarize what you've learned:
-
🔄 Reactive State Management
Changes in ViewModel automatically update UI throughnotifyListeners()andwatchPropertyValue() -
⚠️ Functional Error Handling
Eithertype makes errors explicit and type-safe - no hidden exceptions -
🧩 Widget Composition
Complex UIs are built from simple, reusable pieces (Lego bricks) -
📢 Declarative UI
UI = f(state). You describe what to show, Flutter handles the how -
🎯 Conditional Rendering
Ternary operators and null-aware operators for clean state transitions
- Clear Separation of Concerns: UI ↔ Business Logic ↔ Data Layer
- Testable ViewModels: No UI dependencies, pure Dart logic
- Type-Safe Error Handling: Compiler catches missing error cases
- Performance Optimization: Selective rebuilds with
watchPropertyValue - Maintainable Codebase: SOLID principles, dependency injection
📚 Essential Reading:
- Flutter Codelabs - 15+ hands-on tutorials
- Widget Catalog - Complete widget reference with examples
- Cookbook - Solutions to common patterns
- API Reference - Searchable documentation
🎓 Learning Paths:
- Flutter Basics Course - Official beginner curriculum
- State Management Guide - Choosing the right approach
- Testing Best Practices - Unit, widget, integration tests
The app uses watch_it, but explore these popular options:
| Package | Best For | Learning Curve | Use Case |
|---|---|---|---|
| Riverpod | Modern apps | Medium | Most flexible, compile-time safety |
| Bloc | Enterprise apps | High | Event-driven, Redux-like |
| Provider | Simple apps | Low | Officially recommended by Flutter team |
| GetX | Rapid development | Low | All-in-one (routing, DI, state) |
Recommendation: Start with Provider for learning, graduate to Riverpod for production apps.
Production apps need comprehensive testing:
Unit Tests (Fast, Isolated)
test('selectLocation updates state', () {
final viewModel = ClimateViewModel(repository: mockRepo);
viewModel.selectLocation('Berlin');
expect(viewModel.selectedLocation, 'Berlin');
});Widget Tests (Medium Speed)
testWidgets('Shows error message when API fails', (tester) async {
await tester.pumpWidget(ClimateScreen());
await tester.tap(find.text('Invalid Location'));
await tester.pump();
expect(find.text('Invalid response'), findsOneWidget);
});Integration Tests (Slow, Comprehensive)
testWidgets('Complete user flow', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.menu));
await tester.tap(find.text('Berlin'));
expect(find.byType(ClimateDiagram), findsOneWidget);
});Learn More: Flutter Testing Guide
🤝 Get Help:
- Stack Overflow (flutter tag)
- Flutter Community Slack
- r/FlutterDev - Active Reddit community
- Flutter Discord - Real-time chat
✅ Great Fit:
- Apps needing consistent UI across platforms
- Rapid prototyping and MVPs
- Startups with limited resources
- Apps with heavy animations
- Internal enterprise tools
- Apps requiring cutting-edge native APIs (wait for Flutter support)
- Teams with existing native expertise (migration cost)
- Simple CRUD apps (web might suffice)
- Apps needing smallest possible binary size
# Development
flutter doctor # Check installation & dependencies
flutter pub get # Install packages from pubspec.yaml
flutter run # Run app on connected device
flutter run -d chrome # Run on web browser
flutter run --release # Production build (optimized)
# Debugging
flutter clean # Delete build cache (fixes weird errors)
flutter analyze # Static code analysis
flutter test # Run unit & widget tests
flutter drive # Run integration tests
# Code Generation (for freezed, json_serializable)
flutter pub run build_runner build # Generate once
flutter pub run build_runner watch # Auto-generate on save
flutter pub run build_runner build --delete-conflicting-outputs # Force rebuild
# Device Management
flutter devices # List connected devices
flutter emulators # List available emulators
flutter emulators --launch <id> # Start an emulator
# Performance
flutter pub run devtools # Launch DevTools (profiling, inspector)
flutter run --profile # Profile mode (performance testing)
# Build & Release
flutter build apk # Android APK
flutter build appbundle # Android App Bundle (for Play Store)
flutter build ios # iOS build (requires Mac)
flutter build web # Web deployment// ❌ Rebuilt on every frame
return Container(
child: Text('Hello'),
);
// ✅ Created once at compile-time
return const Text('Hello');// ❌ Everything rebuilds when counter changes
build() {
return Column(
children: [
ExpensiveWidget(), // Rebuilds unnecessarily
Text('$counter'),
],
);
}
// ✅ ExpensiveWidget only rebuilds if its props change
class ExpensiveWidget extends StatelessWidget {
const ExpensiveWidget({super.key});
// ...
}// ❌ Creates new widgets in loop
return Column(
children: items.map((item) => Text(item)).toList(),
);
// ✅ Use ListView.builder (lazy loading)
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => Text(items[index]),
);Happy coding and enjoy building with Flutter! 🚀
Please make sure all artifacts are in this GitHub repository.
That includes:
- Code
- Workshop materials
- Presentation (if applicable)
- References
- Docker (compose) file (if applicable)