DEV SCRIPTS

Flutter Code FAQs

Flutter FAQ

Flutter FAQ

Practical questions & answers with real code examples

100 Questions
Flutter & Dart Fundamentals

Flutter builds a widget tree that describes the UI declaratively. Unlike a DOM, widgets are immutable — when state changes, Flutter rebuilds the subtree and diffs against the previous element tree. The rendering engine (Skia/Impeller) draws directly to a canvas, bypassing native UI components entirely.

dart
// Every piece of UI is a widget composed into a tree
void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('My App')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              Text('Hello', style: TextStyle(fontSize: 24)),
              SizedBox(height: 16),
              Icon(Icons.flutter_dash, size: 64, color: Colors.blue),
            ],
          ),
        ),
      ),
    ),
  );
}

Dart distinguishes nullable (String?) from non-nullable (String) types at compile time. A non-nullable variable can never be null — the compiler rejects assignments without a guaranteed value. Nullability must be explicitly handled before use.

dart
// Non-nullable: must be initialized
String name = 'Flutter'; // OK
// String name; // compile error — must initialize

// Nullable: must check before use
String? maybeNull;
print(maybeNull?.length);       // null-aware access
print(maybeNull ?? 'default');  // null-coalescing

// Late initialization (initialized before first use)
late String config;
config = loadConfig(); // assigned before reading

// Null assertion (throws if null at runtime)
String val = maybeNull!; // avoid unless certain

Hot reload injects updated source code into the running Dart VM and rebuilds the widget tree while preserving app state. Hot restart fully restarts the app, resetting all state. Use hot restart when you change main(), global variables, or initState() logic that only runs on startup.

dart
// Hot reload: change widget appearance safely
class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Change color here → hot reload reflects it instantly
    return Container(color: Colors.blue, height: 100);
  }
}

// Hot RESTART needed: changing initState logic
class _MyPageState extends State<MyPage> {
  late final DatabaseService _db;

  @override
  void initState() {
    super.initState();
    // This only runs on startup — hot reload won't re-run it
    _db = DatabaseService.init();
  }
}

Dart’s Future<T> is equivalent to JavaScript’s Promise<T>. Both use async/await syntax. The key difference: Dart runs on a single-threaded event loop like JS, but Dart also has Isolates for true parallel execution (unlike JS web workers, Isolates share no memory).

dart
// Basic async function
Future<String> fetchUser(int id) async {
  final response = await http.get(Uri.parse('/api/users/$id'));
  if (response.statusCode != 200) throw Exception('Not found');
  return response.body;
}

// Error handling
Future<void> loadData() async {
  try {
    final data = await fetchUser(1);
    print(data);
  } catch (e) {
    print('Error: $e');
  }
}

// Run multiple in parallel (like Promise.all)
Future<void> loadAll() async {
  final results = await Future.wait([
    fetchUser(1),
    fetchUser(2),
    fetchUser(3),
  ]);
  print(results);
}

build() is called whenever Flutter marks the element dirty. Triggers: setState(), parent rebuild passing new props, InheritedWidget dependency change, or didUpdateWidget. Flutter optimizes by comparing the element tree and only re-rendering what changed.

dart
class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  void _increment() {
    // setState marks this element dirty → build() is called
    setState(() {
      _count++;
    });
    // Avoid: don't put async work inside setState callback
  }

  @override
  Widget build(BuildContext context) {
    // Called every time setState fires
    print('build called: $_count');
    return ElevatedButton(
      onPressed: _increment,
      child: Text('Count: $_count'),
    );
  }
}

Flutter projects separate platform code from Dart code. lib/ is where all Dart lives. android/, ios/, web/, windows/ contain native platform shells. pubspec.yaml declares dependencies and assets.

dart
// Recommended lib/ structure:
// lib/
//   main.dart              — entry point
//   app.dart               — MaterialApp setup
//   features/
//     auth/
//       auth_page.dart
//       auth_controller.dart
//       auth_service.dart
//     home/
//       home_page.dart
//   shared/
//     widgets/
//     models/
//     services/

// pubspec.yaml — declare assets and dependencies
// dependencies:
//   flutter:
//     sdk: flutter
//   http: ^1.2.0
//   go_router: ^13.0.0
//
// flutter:
//   assets:
//     - assets/images/
//     - assets/fonts/

Extension methods attach new methods to any type — including built-ins like String, int, or third-party classes — without modifying or subclassing them. They’re resolved at compile time, so they work with existing instances transparently.

dart
// Extend String with useful helpers
extension StringHelpers on String {
  bool get isEmail => contains('@') && contains('.');
  String get capitalize =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
  String truncate(int max) =>
      length > max ? '${substring(0, max)}...' : this;
}

// Extend BuildContext for easy theme access
extension ContextX on BuildContext {
  ThemeData get theme => Theme.of(this);
  TextTheme get textTheme => Theme.of(this).textTheme;
  ColorScheme get colors => Theme.of(this).colorScheme;
}

// Usage
void main() {
  print('flutter@example.com'.isEmail);     // true
  print('hello world'.capitalize);          // Hello world
  print('A very long string'.truncate(10)); // A very lon...
}

Flutter owns its rendering pipeline end-to-end: Dart code → Flutter framework → Skia/Impeller → GPU. It draws every pixel itself. React Native bridges to native widgets (OEM components), meaning UI looks native but has bridge overhead. WebView apps render HTML — slowest and least native-feeling.

dart
// Flutter draws everything via its own renderer
// This button looks identical on Android, iOS, web, desktop
ElevatedButton(
  style: ElevatedButton.styleFrom(
    backgroundColor: const Color(0xFF7B2FBE),
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),
  onPressed: () {},
  child: const Text('Custom Pixel-Perfect Button'),
)

// No platform bridge — the GPU renders this directly
// Consistent appearance and 60/120fps performance
// on all supported platforms
Widgets — Stateless & Stateful

A StatelessWidget is immutable — its output depends only on its constructor arguments. Use it for pure presentation. A StatefulWidget owns mutable state in a companion State object that persists across rebuilds. Choose stateful when the widget needs to react to user input, animations, or async data.

dart
// Stateless: pure function of its inputs
class UserCard extends StatelessWidget {
  const UserCard({super.key, required this.name, required this.email});
  final String name;
  final String email;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(name),
        subtitle: Text(email),
        leading: const CircleAvatar(child: Icon(Icons.person)),
      ),
    );
  }
}

// Stateful: manages its own mutable state
class LikeButton extends StatefulWidget {
  const LikeButton({super.key});
  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> {
  bool _liked = false;
  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(_liked ? Icons.favorite : Icons.favorite_border,
                 color: _liked ? Colors.red : null),
      onPressed: () => setState(() => _liked = !_liked),
    );
  }
}

setState() marks the element dirty and schedules a synchronous rebuild on the next frame. The callback should only mutate state — no async calls, no heavy computation, no side effects. Do the work first, then call setState with the result.

dart
// ❌ Wrong: async work inside setState
void _load() {
  setState(() async {   // setState callback must be sync
    _data = await fetch(); // this won't work correctly
  });
}

// ✅ Correct: do async work first, then setState
Future<void> _load() async {
  final data = await fetchData();
  if (mounted) {       // guard against disposed widget
    setState(() {
      _data = data;    // only mutation here
    });
  }
}

// ✅ Also correct: minimal setState
void _toggle() {
  final next = !_isOpen; // compute outside
  setState(() => _isOpen = next); // just the assignment
}

initState runs once when the widget is inserted into the tree — use it for one-time setup. didUpdateWidget fires when the parent rebuilds with new props. dispose runs on removal — always cancel subscriptions and dispose controllers here.

dart
class _ProfilePageState extends State<ProfilePage> {
  late final StreamSubscription _sub;
  late final TextEditingController _ctrl;

  @override
  void initState() {
    super.initState();              // always call super first
    _ctrl = TextEditingController();
    _sub = userStream.listen(_onUser);
    _loadProfile(widget.userId);   // widget is accessible here
  }

  @override
  void didUpdateWidget(ProfilePage old) {
    super.didUpdateWidget(old);
    if (old.userId != widget.userId) {
      _loadProfile(widget.userId); // respond to prop change
    }
  }

  @override
  void dispose() {
    _sub.cancel();   // cancel streams
    _ctrl.dispose(); // dispose controllers
    super.dispose(); // always call super last
  }

  @override
  Widget build(BuildContext context) => Text(_ctrl.text);
}

Flutter uses constructor parameters exclusively for parent→child data flow (no JSX-style props spread). Mark required fields with required. In stateful widgets, access them inside State via widget.fieldName.

dart
// Parent widget
class ProductList extends StatelessWidget {
  const ProductList({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: products.map((p) => ProductCard(
        product: p,
        onTap: () => openDetail(p.id),
        isFeatured: p.featured,
      )).toList(),
    );
  }
}

// Child widget with typed parameters
class ProductCard extends StatelessWidget {
  const ProductCard({
    super.key,
    required this.product,
    required this.onTap,
    this.isFeatured = false,  // optional with default
  });

  final Product product;
  final VoidCallback onTap;
  final bool isFeatured;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: isFeatured ? Colors.amber.shade50 : null,
      child: ListTile(title: Text(product.name), onTap: onTap),
    );
  }
}

Flutter matches elements across rebuilds by widget type and position. Keys let you explicitly tie an element’s identity to a value. Without a key, reordering a list of stateful widgets causes their state to be reassigned incorrectly. Use ValueKey, ObjectKey, or UniqueKey based on your identifier type.

dart
// ❌ Without keys: swapping items swaps their state incorrectly
ListView(children: items.map((i) => TodoItem(item: i)).toList());

// ✅ With ValueKey: state follows the item's identity
ListView(
  children: items.map((i) => TodoItem(
    key: ValueKey(i.id), // ties element to item.id
    item: i,
  )).toList(),
);

// GlobalKey: access state from outside the widget tree
final _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: Column(children: [...]),
);

// Validate from a button elsewhere
ElevatedButton(
  onPressed: () {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
    }
  },
  child: const Text('Submit'),
)

Composition is the Flutter way: build complex UI by nesting small, focused widgets. Extract repeated subtrees into named widgets with typed parameters. Prefer composition over subclassing existing widgets.

dart
// Reusable stat card widget
class StatCard extends StatelessWidget {
  const StatCard({
    super.key,
    required this.label,
    required this.value,
    required this.icon,
    this.color = Colors.purple,
  });

  final String label;
  final String value;
  final IconData icon;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Container(
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: color.withOpacity(0.1),
                borderRadius: BorderRadius.circular(10),
              ),
              child: Icon(icon, color: color),
            ),
            const SizedBox(width: 12),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(label, style: const TextStyle(color: Colors.grey)),
                Text(value, style: const TextStyle(
                  fontSize: 20, fontWeight: FontWeight.bold)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

BuildContext is a handle to the widget’s location in the tree. After an await, the widget may have been disposed. Accessing context across async gaps triggers the “use_build_context_synchronously” lint. Guard with mounted before using context post-await.

dart
// ❌ Unsafe: context may be invalid after await
Future<void> _save() async {
  await saveData();
  ScaffoldMessenger.of(context).showSnackBar(...); // warning!
}

// ✅ Safe: check mounted before using context
Future<void> _save() async {
  await saveData();
  if (!mounted) return; // widget was disposed during await
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('Saved!')),
  );
}

// ✅ Alternative: capture messenger before await
Future<void> _save() async {
  final messenger = ScaffoldMessenger.of(context); // capture
  await saveData();
  messenger.showSnackBar(const SnackBar(content: Text('Saved!')));
}

A const widget instance is canonicalized — Flutter reuses the same object across builds, skipping the build, layout, and paint phases entirely. Mark widgets const whenever their subtree doesn’t depend on runtime state.

dart
// ❌ Rebuilt on every parent rebuild (even though it never changes)
Text('Static label', style: TextStyle(fontSize: 16));

// ✅ Const: created once, skipped on subsequent rebuilds
const Text('Static label', style: TextStyle(fontSize: 16));

// Only the dynamic part rebuilds
class _HomeState extends State<Home> {
  int _count = 0;
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('Counter'),      // const — never rebuilt
        const Divider(),            // const — never rebuilt
        Text('$_count'),            // rebuilt on setState
        const SizedBox(height: 16), // const — never rebuilt
        ElevatedButton(
          onPressed: () => setState(() => _count++),
          child: const Text('Increment'), // const child
        ),
      ],
    );
  }
}
Layout & Positioning

Row lays children horizontally; Column lays them vertically. The main axis is the direction of layout; cross axis is perpendicular. mainAxisAlignment distributes free space along the main axis; crossAxisAlignment aligns along the cross axis.

dart
// Spread items across full width with space between
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: const [
    Icon(Icons.menu),
    Text('Title', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
    Icon(Icons.more_vert),
  ],
)

// Column centered with even spacing
Column(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  crossAxisAlignment: CrossAxisAlignment.stretch, // children fill width
  children: [
    ElevatedButton(onPressed: () {}, child: const Text('Option A')),
    ElevatedButton(onPressed: () {}, child: const Text('Option B')),
    ElevatedButton(onPressed: () {}, child: const Text('Option C')),
  ],
)

Expanded forces the child to fill all remaining space (equivalent to Flexible(fit: FlexFit.tight)). Flexible with FlexFit.loose lets the child be at most the allocated space but can be smaller. The flex factor sets the proportion.

dart
// Row with proportional widths: 2:1 split
Row(
  children: [
    Expanded(
      flex: 2,       // takes 2/3 of available width
      child: Container(color: Colors.blue, height: 60),
    ),
    Expanded(
      flex: 1,       // takes 1/3 of available width
      child: Container(color: Colors.red, height: 60),
    ),
  ],
)

// Flexible: child can be smaller than allocated space
Row(
  children: [
    Flexible(
      child: Text('Short'),    // only as wide as text needs
    ),
    Flexible(
      child: Text('A much longer text that wraps if needed'),
    ),
    const Icon(Icons.info),    // fixed size, not flexible
  ],
)

Stack layers children in Z order (last child on top). Non-positioned children fill the stack’s size. Positioned places a child at exact coordinates relative to the stack’s boundaries.

dart
// Profile image with online indicator badge
Stack(
  clipBehavior: Clip.none,
  children: [
    // Base: profile image
    CircleAvatar(
      radius: 32,
      backgroundImage: NetworkImage(user.avatarUrl),
    ),
    // Overlay: online dot at bottom-right
    Positioned(
      bottom: 0,
      right: 0,
      child: Container(
        width: 14,
        height: 14,
        decoration: BoxDecoration(
          color: Colors.green,
          shape: BoxShape.circle,
          border: Border.all(color: Colors.white, width: 2),
        ),
      ),
    ),
  ],
)

// Hero image with gradient text overlay
Stack(
  children: [
    Image.network(imageUrl, fit: BoxFit.cover),
    Positioned(
      bottom: 0, left: 0, right: 0,
      child: Container(
        padding: const EdgeInsets.all(12),
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.bottomCenter,
            end: Alignment.topCenter,
            colors: [Colors.black87, Colors.transparent],
          ),
        ),
        child: Text(title, style: const TextStyle(color: Colors.white)),
      ),
    ),
  ],
)

Container is a convenience widget that merges many layout and painting properties. The rendering order is: margin → border → padding → child. When you need only one property, prefer the specific widget (Padding, SizedBox, DecoratedBox) for clarity.

dart
Container(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  padding: const EdgeInsets.all(16),
  width: double.infinity,
  constraints: const BoxConstraints(minHeight: 80, maxHeight: 200),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: const [
      BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2)),
    ],
    border: Border.all(color: Colors.purple.shade100),
  ),
  child: const Text('Card content'),
)

// Prefer specific widgets for single properties:
Padding(
  padding: const EdgeInsets.all(16),
  child: SizedBox(width: 120, height: 40, child: myWidget),
)

MediaQuery.of(context) returns the MediaQueryData with screen size, pixel ratio, padding (safe areas), brightness, and text scale. Access specific values with MediaQuery.sizeOf(context) to avoid rebuilding on unrelated changes.

dart
@override
Widget build(BuildContext context) {
  final size = MediaQuery.sizeOf(context);
  final padding = MediaQuery.paddingOf(context); // safe area
  final isWide = size.width > 600;

  return Scaffold(
    body: Padding(
      // Respect notch/home indicator
      padding: EdgeInsets.only(
        top: padding.top,
        bottom: padding.bottom,
      ),
      child: isWide
          // Two-column layout on tablets/desktop
          ? Row(children: [
              SizedBox(width: size.width * 0.35, child: SidePanel()),
              Expanded(child: MainContent()),
            ])
          // Single column on phone
          : MainContent(),
    ),
  );
}

LayoutBuilder provides the parent’s constraints (not the screen size) to the builder callback, enabling widgets that adapt to their container rather than the global screen width. This is more reusable than MediaQuery for library components.

dart
class AdaptiveCard extends StatelessWidget {
  const AdaptiveCard({super.key, required this.items});
  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth > 500) {
          // Wide container: horizontal chip list
          return Wrap(
            spacing: 8,
            children: items.map((i) => Chip(label: Text(i))).toList(),
          );
        }
        // Narrow container: vertical list
        return Column(
          children: items.map((i) => ListTile(title: Text(i))).toList(),
        );
      },
    );
  }
}

SizedBox creates a fixed-size gap (great for separating list items). Spacer expands to fill all remaining free space in a Row/Column (like margin:auto in CSS). Padding wraps a child with inner space.

dart
// SizedBox: fixed gap between items
Column(
  children: [
    const Text('Username'),
    const SizedBox(height: 8),   // 8px gap
    const TextField(),
    const SizedBox(height: 24),  // 24px gap
    ElevatedButton(onPressed: () {}, child: const Text('Login')),
  ],
)

// Spacer: push items to opposite ends of a Row
Row(
  children: [
    const Text('Logo'),
    const Spacer(), // fills all remaining space
    IconButton(icon: const Icon(Icons.search), onPressed: () {}),
    IconButton(icon: const Icon(Icons.menu),   onPressed: () {}),
  ],
)

// Padding: add space around a child
Padding(
  padding: const EdgeInsets.symmetric(horizontal: 24),
  child: ElevatedButton(onPressed: () {}, child: const Text('Submit')),
)

Wrap places children in a line and starts a new line when the current one is full — like CSS flexbox with flex-wrap: wrap. Control gaps with spacing (main axis) and runSpacing (between lines).

dart
// Tag/chip cloud
Wrap(
  spacing: 8,     // horizontal gap between chips
  runSpacing: 8,  // vertical gap between rows
  children: [
    'Flutter', 'Dart', 'Firebase', 'REST API',
    'Provider', 'Riverpod', 'BLoC',
  ].map((tag) => Chip(
    label: Text(tag),
    backgroundColor: Colors.purple.shade50,
    side: BorderSide(color: Colors.purple.shade200),
  )).toList(),
)

// Responsive button bar that wraps on small screens
Wrap(
  spacing: 12,
  runSpacing: 8,
  alignment: WrapAlignment.center,
  children: [
    ElevatedButton(onPressed: () {}, child: const Text('Save Draft')),
    OutlinedButton(onPressed: () {}, child: const Text('Preview')),
    FilledButton(onPressed: () {}, child: const Text('Publish')),
  ],
)
Navigation & Routing

The Navigator maintains a stack of Route objects. push adds a route; pop removes the top route and optionally returns a result to the caller. MaterialPageRoute provides a platform-appropriate slide transition.

dart
// Push a new page
Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => DetailPage(id: item.id)),
);

// Push and wait for a result
final result = await Navigator.push<String>(
  context,
  MaterialPageRoute(builder: (_) => const EditPage()),
);
if (result != null) showSnackBar('Saved: $result');

// Return result from pushed page
// Inside EditPage:
ElevatedButton(
  onPressed: () => Navigator.pop(context, 'My Result'),
  child: const Text('Done'),
);

// Pop to root (clear back stack)
Navigator.popUntil(context, (route) => route.isFirst);

// Replace current route (no back button)
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (_) => const HomePage()),
);

Register routes in MaterialApp.routes as a map of path strings to builder functions. Use Navigator.pushNamed to navigate and ModalRoute.of(context)!.settings.arguments to retrieve typed arguments.

dart
// Route setup in MaterialApp
MaterialApp(
  initialRoute: '/',
  routes: {
    '/':        (_) => const HomePage(),
    '/product': (_) => const ProductPage(),
    '/cart':    (_) => const CartPage(),
  },
  // For dynamic/parameterized routes:
  onGenerateRoute: (settings) {
    if (settings.name == '/product') {
      final id = settings.arguments as int;
      return MaterialPageRoute(builder: (_) => ProductPage(id: id));
    }
    return null;
  },
)

// Navigate with arguments
Navigator.pushNamed(context, '/product', arguments: 42);

// Retrieve in destination
class ProductPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final id = ModalRoute.of(context)!.settings.arguments as int;
    return Text('Product $id');
  }
}

go_router is the recommended routing package for Flutter. It uses URL-like paths, supports deep links on all platforms, handles shell routes (persistent bottom nav), and provides typed parameters without boilerplate.

dart
final _router = GoRouter(
  initialLocation: '/home',
  routes: [
    GoRoute(
      path: '/home',
      builder: (_, __) => const HomePage(),
    ),
    GoRoute(
      path: '/products',
      builder: (_, __) => const ProductListPage(),
      routes: [
        GoRoute(
          path: ':id',  // nested: /products/42
          builder: (_, state) {
            final id = int.parse(state.pathParameters['id']!);
            return ProductDetailPage(id: id);
          },
        ),
      ],
    ),
  ],
);

// Usage
MaterialApp.router(routerConfig: _router)

// Navigate
context.go('/products/42');
context.push('/products/42'); // add to back stack

Use IndexedStack to keep all tab widgets alive regardless of which is visible. Each tab maintains its own Navigator if it needs internal navigation. With go_router, use ShellRoute to achieve the same.

dart
class _ShellState extends State<ShellPage> {
  int _tab = 0;

  final _pages = const [
    HomePage(),
    SearchPage(),
    ProfilePage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // IndexedStack keeps all tabs mounted — state preserved
      body: IndexedStack(
        index: _tab,
        children: _pages,
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tab,
        onDestinationSelected: (i) => setState(() => _tab = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

In Flutter 3.12+, wrap your widget with PopScope and set canPop: false. The onPopInvokedWithResult callback fires on every pop attempt (hardware back, gesture swipe, or programmatic). Handle the confirmation there.

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

  Future<bool> _confirmExit(BuildContext context) async {
    return await showDialog<bool>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Discard changes?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Keep editing'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Discard'),
          ),
        ],
      ),
    ) ?? false;
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (didPop, _) async {
        if (didPop) return;
        final ok = await _confirmExit(context);
        if (ok && context.mounted) Navigator.pop(context);
      },
      child: const Scaffold(body: FormContent()),
    );
  }
}

Both showModalBottomSheet and showDialog return a Future<T?>. Call Navigator.pop(context, value) inside the sheet/dialog to resolve the future with a result.

dart
// Caller — receives selection
Future<void> _pickColor() async {
  final color = await showModalBottomSheet<Color>(
    context: context,
    builder: (_) => const ColorPickerSheet(),
  );
  if (color != null) setState(() => _selected = color);
}

// Sheet — sends result back
class ColorPickerSheet extends StatelessWidget {
  const ColorPickerSheet({super.key});

  @override
  Widget build(BuildContext context) {
    final colors = [Colors.red, Colors.green, Colors.blue, Colors.purple];
    return Wrap(
      children: colors.map((c) => GestureDetector(
        onTap: () => Navigator.pop(context, c), // return color
        child: Container(
          width: 60, height: 60,
          margin: const EdgeInsets.all(8),
          decoration: BoxDecoration(color: c, shape: BoxShape.circle),
        ),
      )).toList(),
    );
  }
}

In go_router, the redirect callback on GoRouter (or per-route) checks state and returns a path to redirect to, or null to allow navigation. This is the equivalent of React Router’s ProtectedRoute.

dart
final _router = GoRouter(
  redirect: (context, state) {
    final isLoggedIn = AuthService.instance.isAuthenticated;
    final isOnLogin = state.matchedLocation == '/login';

    // Not logged in and not on login page → redirect to login
    if (!isLoggedIn && !isOnLogin) return '/login';

    // Logged in but on login page → redirect to home
    if (isLoggedIn && isOnLogin) return '/home';

    // No redirect needed
    return null;
  },
  routes: [
    GoRoute(path: '/login',  builder: (_, __) => const LoginPage()),
    GoRoute(path: '/home',   builder: (_, __) => const HomePage()),
    GoRoute(path: '/profile',builder: (_, __) => const ProfilePage()),
  ],
);

Insert a semi-transparent overlay on top of the widget tree using Stack and conditional rendering. Wrap it around the Scaffold body, not the navigator, to block interaction while allowing the app bar to remain visible.

dart
class _LoginPageState extends State<LoginPage> {
  bool _loading = false;

  Future<void> _submit() async {
    setState(() => _loading = true);
    try {
      await AuthService.login(_email, _password);
      if (mounted) context.go('/home');
    } catch (e) {
      if (mounted) showErrorSnackBar(context, e.toString());
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          LoginForm(onSubmit: _submit),
          if (_loading)
            const ColoredBox(
              color: Color(0x88000000), // semi-transparent
              child: Center(child: CircularProgressIndicator()),
            ),
        ],
      ),
    );
  }
}
State Management

InheritedWidget is the low-level mechanism Flutter uses for its own context APIs (Theme.of, MediaQuery.of). Descendant widgets call context.dependOnInheritedWidgetOfExactType<T>() and are automatically rebuilt when the inherited widget changes.

dart
class AppTheme extends InheritedWidget {
  const AppTheme({
    super.key,
    required this.isDark,
    required super.child,
  });

  final bool isDark;

  // Convenience accessor
  static AppTheme of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppTheme>()!;
  }

  @override
  bool updateShouldNotify(AppTheme old) => isDark != old.isDark;
}

// Any descendant can access it:
Widget build(BuildContext context) {
  final isDark = AppTheme.of(context).isDark;
  return Text(isDark ? 'Dark mode' : 'Light mode');
}

Provider wraps InheritedWidget with a simpler API. ChangeNotifierProvider creates a ChangeNotifier and disposes it automatically. Consumer or context.watch() subscribe to updates; context.read() accesses without subscribing.

dart
// Model
class CartModel extends ChangeNotifier {
  final List<Item> _items = [];
  List<Item> get items => List.unmodifiable(_items);
  int get count => _items.length;

  void add(Item item) {
    _items.add(item);
    notifyListeners(); // triggers rebuild of listeners
  }
}

// Provide at top of tree
ChangeNotifierProvider(
  create: (_) => CartModel(),
  child: const MyApp(),
)

// Read (no rebuild) vs Watch (rebuilds on change)
class CartButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final count = context.watch<CartModel>().count; // rebuilds
    return Badge(label: Text('$count'), child: const Icon(Icons.cart));
  }
}

// Trigger action without subscribing
TextButton(
  onPressed: () => context.read<CartModel>().add(item),
  child: const Text('Add to cart'),
)

Riverpod has no BuildContext dependency — providers live globally and are accessed via a WidgetRef. This makes them easy to test in isolation. Providers are compile-time safe (no runtime “provider not found” errors) and support fine-grained rebuilds via select.

dart
// Define providers at file level (not in widget tree)
final userProvider = AsyncNotifierProvider<UserNotifier, User>(
  UserNotifier.new,
);

class UserNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() => ref.watch(authServiceProvider).getUser();

  Future<void> updateName(String name) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => api.updateName(name));
  }
}

// Consumer widget
class ProfileWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider);
    return user.when(
      data: (u) => Text(u.name),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
    );
  }
}

BLoC (Business Logic Component) takes a stream of events as input and emits a stream of states. The UI sends events and renders states — no business logic in widgets. The flutter_bloc package provides Bloc/Cubit classes and BlocBuilder/BlocListener widgets.

dart
// Events
sealed class AuthEvent {}
class LoginRequested extends AuthEvent {
  LoginRequested(this.email, this.password);
  final String email, password;
}
class LogoutRequested extends AuthEvent {}

// States
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState { AuthSuccess(this.user); final User user; }
class AuthFailure extends AuthState { AuthFailure(this.error); final String error; }

// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(AuthInitial()) {
    on<LoginRequested>((event, emit) async {
      emit(AuthLoading());
      try {
        final user = await AuthService.login(event.email, event.password);
        emit(AuthSuccess(user));
      } catch (e) {
        emit(AuthFailure(e.toString()));
      }
    });
  }
}

// UI
BlocBuilder<AuthBloc, AuthState>(
  builder: (context, state) {
    return switch (state) {
      AuthLoading()  => const CircularProgressIndicator(),
      AuthSuccess(:final user) => Text('Welcome ${user.name}'),
      AuthFailure(:final error) => Text(error, style: const TextStyle(color: Colors.red)),
      _ => const LoginForm(),
    };
  },
)

StreamBuilder listens to a Stream<T> and rebuilds with a new AsyncSnapshot<T> on each emission. It handles all connection states: waiting, active, done, and error. Use it to connect Firebase real-time streams, WebSockets, or Dart streams directly to UI.

dart
// Real-time chat messages from Firebase
StreamBuilder<List<Message>>(
  stream: FirebaseFirestore.instance
      .collection('messages')
      .orderBy('timestamp', descending: true)
      .snapshots()
      .map((snap) => snap.docs.map(Message.fromDoc).toList()),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Center(child: CircularProgressIndicator());
    }
    if (snapshot.hasError) {
      return Center(child: Text('Error: ${snapshot.error}'));
    }
    final messages = snapshot.data ?? [];
    if (messages.isEmpty) return const Center(child: Text('No messages'));
    return ListView.builder(
      reverse: true,
      itemCount: messages.length,
      itemBuilder: (_, i) => MessageBubble(message: messages[i]),
    );
  },
)

ValueNotifier<T> is a ChangeNotifier that holds a single value. When value changes, only the ValueListenableBuilder widget rebuilds — not its parent or siblings. Ideal for localized UI state like toggles, counters, or selected indices.

dart
class _FeedState extends State<FeedPage> {
  // Defined once, persists across builds
  final _selectedFilter = ValueNotifier<String>('all');

  @override
  void dispose() {
    _selectedFilter.dispose(); // always dispose
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Only this widget rebuilds when filter changes
        ValueListenableBuilder<String>(
          valueListenable: _selectedFilter,
          builder: (_, filter, __) => FilterBar(
            selected: filter,
            onSelect: (f) => _selectedFilter.value = f,
          ),
        ),
        // This expensive list does NOT rebuild on filter change
        const Expanded(child: ExpensivePostList()),
      ],
    );
  }
}

FutureBuilder<T> subscribes to a Future and rebuilds with an AsyncSnapshot. Keep the future in a field — reassigning in build() creates a new future every rebuild, restarting the loading cycle.

dart
class ProductPage extends StatefulWidget {
  const ProductPage({super.key, required this.id});
  final int id;
  @override
  State<ProductPage> createState() => _ProductPageState();
}

class _ProductPageState extends State<ProductPage> {
  // Store future in state — not created inside build()
  late final Future<Product> _future;

  @override
  void initState() {
    super.initState();
    _future = ProductService.fetch(widget.id);
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Product>(
      future: _future,
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Error: ${snapshot.error}'));
        }
        final product = snapshot.data!;
        return ProductDetail(product: product);
      },
    );
  }
}

Match the tool to the scope and complexity of the state: setState for local widget state, Provider/Riverpod for app-wide reactive state, BLoC for complex event-driven business logic with testable separation.

dart
// setState — local, simple, no sharing needed
// e.g., toggle button, form field focus, tab index
setState(() => _isOpen = !_isOpen);

// Provider / Riverpod — cross-widget, reactive, testable
// e.g., user session, cart, theme, settings
final cartProvider = NotifierProvider<Cart, List<Item>>(Cart.new);

// BLoC — complex flows with many events/states
// e.g., authentication flow, checkout process, search with debounce
class SearchBloc extends Bloc<SearchEvent, SearchState> { ... }

// Decision guide:
// Single widget state         → setState
// 2-3 widget sharing         → Provider (ChangeNotifier)
// Large app, testability key → Riverpod
// Complex event-driven logic → BLoC
Forms & Input

TextEditingController exposes .text to read the current value, .value to read/set with cursor position, and addListener to react to every keystroke. Always dispose it in dispose().

dart
class _SearchBarState extends State<SearchBar> {
  final _ctrl = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Pre-fill existing value
    _ctrl.text = widget.initialQuery;

    // Listen to every change (debounce in real apps)
    _ctrl.addListener(() {
      widget.onQueryChanged(_ctrl.text);
    });
  }

  @override
  void dispose() {
    _ctrl.dispose(); // must dispose to free resources
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _ctrl,
      onSubmitted: widget.onSearch,
      decoration: InputDecoration(
        hintText: 'Search...',
        suffixIcon: IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () => _ctrl.clear(),
        ),
      ),
    );
  }
}

Wrap fields in a Form widget with a GlobalKey<FormState>. Use TextFormField with a validator callback. Call formKey.currentState!.validate() to trigger all validators simultaneously.

dart
class RegisterForm extends StatefulWidget {
  const RegisterForm({super.key});
  @override
  State<RegisterForm> createState() => _RegisterFormState();
}

class _RegisterFormState extends State<RegisterForm> {
  final _formKey = GlobalKey<FormState>();
  String _email = '', _password = '';

  void _submit() {
    if (!_formKey.currentState!.validate()) return;
    _formKey.currentState!.save(); // triggers onSaved callbacks
    AuthService.register(_email, _password);
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(children: [
        TextFormField(
          decoration: const InputDecoration(labelText: 'Email'),
          keyboardType: TextInputType.emailAddress,
          validator: (v) {
            if (v == null || v.isEmpty) return 'Required';
            if (!v.contains('@')) return 'Invalid email';
            return null; // null = valid
          },
          onSaved: (v) => _email = v!,
        ),
        const SizedBox(height: 16),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Password'),
          obscureText: true,
          validator: (v) => (v?.length ?? 0) < 8 ? 'Min 8 chars' : null,
          onSaved: (v) => _password = v!,
        ),
        const SizedBox(height: 24),
        ElevatedButton(onPressed: _submit, child: const Text('Register')),
      ]),
    );
  }
}

FocusNode programmatically moves focus between fields. Call FocusScope.of(context).requestFocus(nextFocusNode) to jump to the next field when the user presses “Next” on the keyboard.

dart
class _LoginFormState extends State<LoginForm> {
  final _emailFocus    = FocusNode();
  final _passwordFocus = FocusNode();

  @override
  void dispose() {
    _emailFocus.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      TextFormField(
        focusNode: _emailFocus,
        textInputAction: TextInputAction.next, // shows "Next" key
        onFieldSubmitted: (_) =>
          FocusScope.of(context).requestFocus(_passwordFocus),
        decoration: const InputDecoration(labelText: 'Email'),
      ),
      TextFormField(
        focusNode: _passwordFocus,
        textInputAction: TextInputAction.done, // shows "Done" key
        onFieldSubmitted: (_) => _submit(),
        obscureText: true,
        decoration: const InputDecoration(labelText: 'Password'),
      ),
    ]);
  }
}

GestureDetector wraps any widget with gesture recognition callbacks. For simple tap/ripple effects on Material, prefer InkWell. Use GestureDetector for custom gestures or non-Material components.

dart
// Custom swipe-to-dismiss card
GestureDetector(
  onTap: () => openDetail(item),
  onLongPress: () => showContextMenu(item),
  onHorizontalDragEnd: (details) {
    if (details.velocity.pixelsPerSecond.dx > 300) {
      dismiss(item); // fast swipe right
    }
  },
  child: Card(child: Text(item.title)),
)

// InkWell for Material ripple
InkWell(
  borderRadius: BorderRadius.circular(12),
  onTap: () => navigate(context, item),
  child: Padding(
    padding: const EdgeInsets.all(12),
    child: Text(item.title),
  ),
)

// Double tap to zoom image
GestureDetector(
  onDoubleTap: () => setState(() => _zoomed = !_zoomed),
  child: AnimatedScale(
    scale: _zoomed ? 2.0 : 1.0,
    duration: const Duration(milliseconds: 300),
    child: Image.network(imageUrl),
  ),
)

All toggle inputs in Flutter are controlled: they require an explicit state variable. CheckboxListTile, RadioListTile, and SwitchListTile provide pre-built layout with labels.

dart
class _PrefsState extends State<PrefsPage> {
  bool _notifications = true;
  bool _darkMode = false;
  String _theme = 'system';

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      // Switch
      SwitchListTile(
        title: const Text('Notifications'),
        value: _notifications,
        onChanged: (v) => setState(() => _notifications = v),
      ),

      // Checkbox
      CheckboxListTile(
        title: const Text('Dark Mode'),
        value: _darkMode,
        onChanged: (v) => setState(() => _darkMode = v ?? false),
      ),

      // Radio buttons
      for (final opt in ['system', 'light', 'dark'])
        RadioListTile<String>(
          title: Text(opt),
          value: opt,
          groupValue: _theme,
          onChanged: (v) => setState(() => _theme = v!),
        ),
    ]);
  }
}

DropdownButton<T> requires a value matching one of the items. The onChanged callback receives the selected value. Use DropdownButtonFormField inside a Form to get built-in validation.

dart
class _CountryPickerState extends State<CountryPicker> {
  String? _selected;
  final _countries = ['USA', 'UK', 'Canada', 'Australia', 'Israel'];

  @override
  Widget build(BuildContext context) {
    return DropdownButtonFormField<String>(
      value: _selected,
      hint: const Text('Select country'),
      decoration: const InputDecoration(
        labelText: 'Country',
        border: OutlineInputBorder(),
      ),
      items: _countries
          .map((c) => DropdownMenuItem(value: c, child: Text(c)))
          .toList(),
      onChanged: (v) => setState(() => _selected = v),
      validator: (v) => v == null ? 'Please select a country' : null,
    );
  }
}

showDatePicker and showTimePicker are Material dialog futures that return a nullable DateTime/TimeOfDay. Combine them for a full datetime picker. Both support firstDate/lastDate bounds.

dart
class _EventFormState extends State<EventForm> {
  DateTime? _eventDate;
  TimeOfDay? _eventTime;

  Future<void> _pickDateTime() async {
    // Step 1: pick date
    final date = await showDatePicker(
      context: context,
      initialDate: DateTime.now(),
      firstDate: DateTime.now(),
      lastDate: DateTime.now().add(const Duration(days: 365)),
    );
    if (date == null || !mounted) return;

    // Step 2: pick time
    final time = await showTimePicker(
      context: context,
      initialTime: TimeOfDay.now(),
    );
    if (time == null) return;

    setState(() {
      _eventDate = date;
      _eventTime = time;
    });
  }

  @override
  Widget build(BuildContext context) {
    final label = _eventDate == null
        ? 'Pick date & time'
        : '${_eventDate!.toLocal()} ${_eventTime?.format(context)}';

    return ListTile(
      leading: const Icon(Icons.calendar_today),
      title: Text(label),
      onTap: _pickDateTime,
    );
  }
}

Use a Timer that resets on every keystroke. When the timer fires (after the delay passes without a new keystroke), execute the search. Cancel the timer in dispose to prevent leaks.

dart
class _SearchBarState extends State<SearchBar> {
  final _ctrl = TextEditingController();
  Timer? _debounce;

  void _onChanged(String query) {
    _debounce?.cancel(); // cancel previous timer
    _debounce = Timer(const Duration(milliseconds: 400), () {
      // Only fires if 400ms pass without another keystroke
      widget.onSearch(query);
    });
  }

  @override
  void dispose() {
    _debounce?.cancel();
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _ctrl,
      onChanged: _onChanged,
      decoration: const InputDecoration(
        prefixIcon: Icon(Icons.search),
        hintText: 'Search products...',
        border: OutlineInputBorder(),
      ),
    );
  }
}
Networking & Data

The http package provides simple top-level functions: http.get, http.post, etc. Always check response.statusCode — a 200 doesn’t mean the data is valid. Decode the body with json.decode.

dart
import 'dart:convert';
import 'package:http/http.dart' as http;

// GET request
Future<User> fetchUser(int id) async {
  final uri = Uri.parse('https://api.example.com/users/$id');
  final response = await http.get(uri, headers: {
    'Authorization': 'Bearer $token',
    'Content-Type': 'application/json',
  });

  if (response.statusCode != 200) {
    throw Exception('Failed: ${response.statusCode}');
  }
  return User.fromJson(json.decode(response.body));
}

// POST request
Future<User> createUser(String name, String email) async {
  final response = await http.post(
    Uri.parse('https://api.example.com/users'),
    headers: {'Content-Type': 'application/json'},
    body: json.encode({'name': name, 'email': email}),
  );
  if (response.statusCode != 201) throw Exception('Create failed');
  return User.fromJson(json.decode(response.body));
}

Define a model class with a fromJson factory constructor that reads from a Map<String, dynamic>. For production apps, use json_serializable or freezed to generate the boilerplate automatically.

dart
// Manual model class
class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.tags,
    this.imageUrl,
  });

  final int id;
  final String name;
  final double price;
  final List<String> tags;
  final String? imageUrl;

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'] as int,
      name: json['name'] as String,
      price: (json['price'] as num).toDouble(),
      tags: List<String>.from(json['tags'] as List),
      imageUrl: json['image_url'] as String?,
    );
  }

  // Parse list response
  static List<Product> fromJsonList(List<dynamic> list) =>
      list.map((j) => Product.fromJson(j as Map<String, dynamic>)).toList();
}

Dio is a more powerful HTTP client than http. It supports interceptors (for auth headers, logging, token refresh), configurable timeouts, built-in JSON decoding, FormData uploads, and typed error handling via DioException.

dart
final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 15),
));

// Auth interceptor
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer ${Storage.token}';
    handler.next(options);
  },
  onError: (DioException e, handler) async {
    if (e.response?.statusCode == 401) {
      // Token expired — refresh and retry
      await AuthService.refreshToken();
      return handler.resolve(await dio.fetch(e.requestOptions));
    }
    handler.next(e);
  },
));

// Usage: Dio auto-decodes JSON
final response = await dio.get<Map<String, dynamic>>('/users/1');
final user = User.fromJson(response.data!);

Use MultipartRequest (http package) or FormData (Dio) to send files as multipart form data. Pick the file with image_picker, then attach it to the request.

dart
import 'package:image_picker/image_picker.dart';

Future<void> uploadAvatar() async {
  // 1. Pick image
  final picked = await ImagePicker().pickImage(
    source: ImageSource.gallery,
    maxWidth: 800,
    imageQuality: 80,
  );
  if (picked == null) return;

  // 2. Upload with Dio
  final formData = FormData.fromMap({
    'avatar': await MultipartFile.fromFile(
      picked.path,
      filename: 'avatar.jpg',
    ),
    'user_id': currentUser.id,
  });

  final response = await dio.post('/users/avatar', data: formData,
    onSendProgress: (sent, total) {
      setState(() => _progress = sent / total);
    },
  );
  setState(() => _avatarUrl = response.data['url']);
}

Attach a ScrollController to the list and listen for when the user scrolls near the bottom. When the threshold is hit, fetch the next page and append to the existing list.

dart
class _FeedState extends State<FeedPage> {
  final _scrollCtrl = ScrollController();
  final _items = <Post>[];
  int _page = 1;
  bool _loading = false;
  bool _hasMore = true;

  @override
  void initState() {
    super.initState();
    _loadPage();
    _scrollCtrl.addListener(_onScroll);
  }

  void _onScroll() {
    final pos = _scrollCtrl.position;
    if (pos.pixels >= pos.maxScrollExtent - 200 && !_loading && _hasMore) {
      _loadPage();
    }
  }

  Future<void> _loadPage() async {
    setState(() => _loading = true);
    final newPosts = await PostService.fetch(page: _page);
    setState(() {
      _items.addAll(newPosts);
      _page++;
      _hasMore = newPosts.length == 20; // 20 per page
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollCtrl,
      itemCount: _items.length + (_loading ? 1 : 0),
      itemBuilder: (_, i) {
        if (i == _items.length) return const Center(child: CircularProgressIndicator());
        return PostCard(post: _items[i]);
      },
    );
  }
}

sqflite provides a SQLite database on Android and iOS. Open a database with a migration-aware onCreate/onUpgrade callback. Use insert, query, update, delete for CRUD.

dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class AppDatabase {
  static Database? _db;

  static Future<Database> get db async {
    _db ??= await openDatabase(
      join(await getDatabasesPath(), 'app.db'),
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE notes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            body TEXT,
            created_at INTEGER
          )
        ''');
      },
    );
    return _db!;
  }

  static Future<int> insertNote(Map<String, dynamic> note) async {
    return (await db).insert('notes', note,
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  static Future<List<Map<String, dynamic>>> getNotes() async {
    return (await db).query('notes', orderBy: 'created_at DESC');
  }
}

shared_preferences stores primitive values (bool, int, double, String, List<String>) on disk via platform-native key-value stores. Get an instance once and reuse it; the data persists between app launches.

dart
import 'package:shared_preferences/shared_preferences.dart';

class PrefsService {
  static late SharedPreferences _prefs;

  static Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  // Theme
  static bool get isDark => _prefs.getBool('dark_mode') ?? false;
  static Future<void> setDark(bool v) => _prefs.setBool('dark_mode', v);

  // Auth token
  static String? get token => _prefs.getString('auth_token');
  static Future<void> setToken(String t) => _prefs.setString('auth_token', t);
  static Future<void> clearToken() => _prefs.remove('auth_token');

  // First launch
  static bool get isFirstRun => _prefs.getBool('first_run') ?? true;
  static Future<void> markLaunched() => _prefs.setBool('first_run', false);
}

// Initialize once in main:
// await PrefsService.init();

The connectivity_plus package streams connectivity status changes. Subscribe in initState and cancel in dispose. Note: connectivity status doesn’t guarantee internet access — use a test request to confirm.

dart
import 'package:connectivity_plus/connectivity_plus.dart';

class _AppState extends State<App> {
  late final StreamSubscription<List<ConnectivityResult>> _sub;
  bool _isOnline = true;

  @override
  void initState() {
    super.initState();
    _sub = Connectivity().onConnectivityChanged.listen((results) {
      setState(() {
        _isOnline = results.any((r) => r != ConnectivityResult.none);
      });
    });
  }

  @override
  void dispose() {
    _sub.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      if (!_isOnline)
        MaterialBanner(
          content: const Text('No internet connection'),
          backgroundColor: Colors.red.shade100,
          actions: [TextButton(onPressed: () {}, child: const Text('Retry'))],
        ),
      Expanded(child: const MainContent()),
    ]);
  }
}
Lists & Scrolling

ListView.builder creates items on demand — only those visible (plus a small buffer) are built. It’s a virtual list. Never use ListView(children: [...]) with large collections, as it builds all children at once.

dart
// ❌ Eager: builds all 10,000 items at once
ListView(
  children: products.map((p) => ProductCard(product: p)).toList(),
)

// ✅ Lazy: builds only visible items
ListView.builder(
  itemCount: products.length,
  itemExtent: 80,           // fixed height enables more optimizations
  itemBuilder: (context, index) {
    final product = products[index];
    return ProductCard(
      key: ValueKey(product.id),
      product: product,
    );
  },
)

// Separated: adds a divider between items
ListView.separated(
  itemCount: items.length,
  separatorBuilder: (_, __) => const Divider(height: 1),
  itemBuilder: (_, i) => ListTile(title: Text(items[i].name)),
)

GridView.builder with SliverGridDelegateWithFixedCrossAxisCount creates an N-column grid. SliverGridDelegateWithMaxCrossAxisExtent creates a responsive grid where each cell has a maximum width.

dart
// Fixed 2-column grid
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
    childAspectRatio: 0.75, // height = width / 0.75
  ),
  itemCount: products.length,
  itemBuilder: (_, i) => ProductCard(product: products[i]),
)

// Responsive: each cell max 200px wide
GridView.builder(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 200,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
    childAspectRatio: 1.0,  // square cells
  ),
  itemCount: photos.length,
  itemBuilder: (_, i) => Image.network(photos[i].url, fit: BoxFit.cover),
)

Wrap a scrollable widget with RefreshIndicator and provide an async onRefresh callback. The indicator remains visible until the future completes. The scrollable must be scrollable even when content is shorter than the screen — set physics: const AlwaysScrollableScrollPhysics().

dart
class _PostListState extends State<PostList> {
  List<Post> _posts = [];

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    final posts = await PostService.fetchLatest();
    if (mounted) setState(() => _posts = posts);
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _load, // called when user pulls down
      color: Theme.of(context).colorScheme.primary,
      child: ListView.builder(
        // Required: makes short lists still pullable
        physics: const AlwaysScrollableScrollPhysics(),
        itemCount: _posts.length,
        itemBuilder: (_, i) => PostCard(post: _posts[i]),
      ),
    );
  }
}

CustomScrollView composes multiple scrollable sections (slivers) into one unified scroll. Mix SliverAppBar (collapsing header), SliverList, SliverGrid, and SliverToBoxAdapter (any widget) in the same scroll view.

dart
CustomScrollView(
  slivers: [
    // Collapsing hero image header
    SliverAppBar(
      expandedHeight: 280,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(
        title: Text(product.name),
        background: Image.network(product.imageUrl, fit: BoxFit.cover),
      ),
    ),

    // Static info section
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: ProductInfo(product: product),
      ),
    ),

    // Section header
    const SliverToBoxAdapter(
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: Text('Related Products', style: TextStyle(fontWeight: FontWeight.bold)),
      ),
    ),

    // Grid of related items
    SliverGrid(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8,
        childAspectRatio: 0.8,
      ),
      delegate: SliverChildBuilderDelegate(
        (_, i) => ProductCard(product: related[i]),
        childCount: related.length,
      ),
    ),
  ],
)

ReorderableListView.builder shows a drag handle on each item and calls onReorder with the old and new indices. Update the list manually in onReorder — Flutter doesn’t mutate it for you.

dart
class _TaskListState extends State<TaskList> {
  final _tasks = ['Write tests', 'Review PR', 'Deploy', 'Update docs'];

  @override
  Widget build(BuildContext context) {
    return ReorderableListView.builder(
      itemCount: _tasks.length,
      onReorder: (oldIndex, newIndex) {
        setState(() {
          // Required correction per Flutter docs
          if (newIndex > oldIndex) newIndex--;
          final item = _tasks.removeAt(oldIndex);
          _tasks.insert(newIndex, item);
        });
      },
      itemBuilder: (_, i) => ListTile(
        key: ValueKey(_tasks[i]), // required for reorderable
        title: Text(_tasks[i]),
        leading: const Icon(Icons.drag_handle),
      ),
    );
  }
}

Wrap each list item in Dismissible. The onDismissed callback fires after the swipe animation completes. Remove the item from the underlying list; otherwise Flutter throws a layout error when it tries to rebuild the dismissed item.

dart
ListView.builder(
  itemCount: _emails.length,
  itemBuilder: (_, i) {
    final email = _emails[i];
    return Dismissible(
      key: ValueKey(email.id),
      direction: DismissDirection.endToStart, // swipe left only
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      confirmDismiss: (_) async {
        return await showDialog<bool>(
          context: context,
          builder: (_) => AlertDialog(
            title: const Text('Delete email?'),
            actions: [
              TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
              FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
            ],
          ),
        );
      },
      onDismissed: (_) {
        setState(() => _emails.removeAt(i));
        ScaffoldMessenger.of(context)
          .showSnackBar(const SnackBar(content: Text('Email deleted')));
      },
      child: EmailTile(email: email),
    );
  },
)

PageView is a scrollable list of full-screen pages. A PageController lets you animate to a specific page programmatically. Use PageView.builder for large or dynamic page sets.

dart
class _OnboardingState extends State<OnboardingScreen> {
  final _ctrl = PageController();
  int _page = 0;

  final _pages = const [
    OnboardPage(title: 'Discover', icon: Icons.explore),
    OnboardPage(title: 'Connect', icon: Icons.people),
    OnboardPage(title: 'Achieve', icon: Icons.emoji_events),
  ];

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(
        controller: _ctrl,
        onPageChanged: (p) => setState(() => _page = p),
        children: _pages,
      ),
      bottomNavigationBar: Padding(
        padding: const EdgeInsets.all(24),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            // Dot indicators
            Row(children: List.generate(_pages.length, (i) => AnimatedContainer(
              duration: const Duration(milliseconds: 200),
              margin: const EdgeInsets.only(right: 6),
              width: _page == i ? 20 : 8, height: 8,
              decoration: BoxDecoration(
                color: _page == i ? Colors.purple : Colors.grey.shade300,
                borderRadius: BorderRadius.circular(4),
              ),
            ))),
            FilledButton(
              onPressed: _page == _pages.length - 1
                ? () => context.go('/home')
                : () => _ctrl.nextPage(
                    duration: const Duration(milliseconds: 300),
                    curve: Curves.easeInOut),
              child: Text(_page == _pages.length - 1 ? 'Start' : 'Next'),
            ),
          ],
        ),
      ),
    );
  }
}

AnimatedList wraps a GlobalKey<AnimatedListState>. Call insertItem/removeItem on the key’s current state to trigger transitions. The itemBuilder receives an Animation for the insert; removeItem takes a builder for the exit animation.

dart
class _NotifListState extends State<NotifList> {
  final _listKey = GlobalKey<AnimatedListState>();
  final _items = <Notification>[];

  void _add(Notification n) {
    _items.insert(0, n);
    _listKey.currentState!.insertItem(0,
      duration: const Duration(milliseconds: 300));
  }

  void _remove(int i) {
    final removed = _items.removeAt(i);
    _listKey.currentState!.removeItem(i,
      (_, animation) => FadeTransition(
        opacity: animation,
        child: NotifCard(notif: removed),
      ),
      duration: const Duration(milliseconds: 200),
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedList(
      key: _listKey,
      initialItemCount: _items.length,
      itemBuilder: (_, i, animation) => SlideTransition(
        position: animation.drive(Tween(
          begin: const Offset(1, 0), end: Offset.zero)),
        child: NotifCard(notif: _items[i]),
      ),
    );
  }
}
Animations

Change any property on AnimatedContainer inside setState and it smoothly interpolates to the new value. No animation controller needed — this is Flutter’s “implicit animation” family (AnimatedOpacity, AnimatedPadding, AnimatedPositioned, etc.).

dart
class _ExpandingCardState extends State<ExpandingCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 350),
        curve: Curves.easeInOut,
        // All these properties animate on setState:
        height: _expanded ? 200 : 80,
        decoration: BoxDecoration(
          color: _expanded ? Colors.purple : Colors.purple.shade100,
          borderRadius: BorderRadius.circular(_expanded ? 16 : 40),
          boxShadow: _expanded
              ? [const BoxShadow(blurRadius: 20, color: Colors.black26)]
              : [],
        ),
        child: Center(
          child: Text(
            _expanded ? 'Tap to collapse' : 'Tap to expand',
            style: TextStyle(
              color: _expanded ? Colors.white : Colors.purple,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }
}

AnimationController is a value that runs from 0 to 1 over a duration. A Tween maps that 0–1 to any range (color, size, offset). Add a CurvedAnimation for easing. Use with AnimatedBuilder to rebuild only the animated subtree.

dart
class _PulseState extends State<PulseButton>
    with SingleTickerProviderStateMixin {

  late final AnimationController _ctrl;
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    )..repeat(reverse: true); // ping-pong loop

    _scale = Tween(begin: 1.0, end: 1.15).animate(
      CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
    );
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _scale,
      builder: (_, child) => Transform.scale(
        scale: _scale.value,
        child: child,
      ),
      child: FloatingActionButton(
        onPressed: widget.onPressed,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Wrap the same widget on both the source and destination screens with Hero, giving both the same tag. Flutter automatically animates the shared element flying between the two pages during navigation.

dart
// List screen — source
class ProductListItem extends StatelessWidget {
  const ProductListItem({super.key, required this.product});
  final Product product;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => Navigator.push(context,
        MaterialPageRoute(builder: (_) => ProductDetail(product: product))),
      child: Hero(
        tag: 'product-image-${product.id}', // unique tag
        child: ClipRRect(
          borderRadius: BorderRadius.circular(12),
          child: Image.network(product.imageUrl, width: 80, height: 80, fit: BoxFit.cover),
        ),
      ),
    );
  }
}

// Detail screen — destination
class ProductDetail extends StatelessWidget {
  const ProductDetail({super.key, required this.product});
  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(children: [
        Hero(
          tag: 'product-image-${product.id}', // same tag!
          child: Image.network(product.imageUrl, width: double.infinity,
              height: 300, fit: BoxFit.cover),
        ),
        Text(product.name, style: const TextStyle(fontSize: 24)),
      ]),
    );
  }
}

TweenAnimationBuilder animates to a new target value whenever it changes — like AnimatedContainer but for arbitrary types. It’s a one-way animation (not looping). Use it for progress indicators, counter animations, or color transitions.

dart
// Animated score counter
TweenAnimationBuilder<int>(
  tween: IntTween(begin: 0, end: score), // animates to new score
  duration: const Duration(milliseconds: 600),
  curve: Curves.easeOut,
  builder: (_, value, __) => Text(
    '$value',
    style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
  ),
)

// Animated color fill (e.g., loading bar)
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0.0, end: progress), // 0.0 to 1.0
  duration: const Duration(milliseconds: 800),
  builder: (_, value, __) => LinearProgressIndicator(value: value),
)

// Animated color theme switch
TweenAnimationBuilder<Color?>(
  tween: ColorTween(begin: Colors.blue, end: targetColor),
  duration: const Duration(milliseconds: 500),
  builder: (_, color, child) => Container(color: color, child: child),
  child: const Text('Content'),
)

Use the shimmer package (or implement manually with an AnimationController driving a gradient). Show the skeleton while the real data loads, then replace it with the actual content.

dart
import 'package:shimmer/shimmer.dart';

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

  @override
  Widget build(BuildContext context) {
    return Shimmer.fromColors(
      baseColor: Colors.grey.shade300,
      highlightColor: Colors.grey.shade100,
      child: Card(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Image placeholder
            Container(height: 160, color: Colors.white),
            const SizedBox(height: 8),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Container(height: 16, width: 140, color: Colors.white),
                  const SizedBox(height: 6),
                  Container(height: 12, width: 80, color: Colors.white),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// Toggle between skeleton and real content:
_loading ? const ProductCardSkeleton() : ProductCard(product: product)

AnimatedSwitcher fades (or transitions) between old and new child widgets when the child changes. The child must have a different key to trigger the animation. Use it for toggling icons, loading states, or slide-show content.

dart
// Toggle heart icon with fade+scale
AnimatedSwitcher(
  duration: const Duration(milliseconds: 250),
  transitionBuilder: (child, animation) => ScaleTransition(
    scale: animation,
    child: FadeTransition(opacity: animation, child: child),
  ),
  child: Icon(
    _liked ? Icons.favorite : Icons.favorite_border,
    key: ValueKey(_liked), // key change triggers animation!
    color: _liked ? Colors.red : Colors.grey,
    size: 28,
  ),
)

// Swap between loading spinner and content
AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: _loading
      ? const CircularProgressIndicator(key: ValueKey('loading'))
      : UserCard(user: _user!, key: const ValueKey('content')),
)

The lottie package renders Bodymovin/Lottie JSON animations exported from After Effects or LottieFiles. Load from assets, network, or bytes. Use a controller to play, pause, loop, or seek.

dart
import 'package:lottie/lottie.dart';

// Simple loop from assets (add to pubspec.yaml assets)
Lottie.asset(
  'assets/animations/loading.json',
  width: 120,
  height: 120,
  fit: BoxFit.contain,
)

// Controlled: play once then stop
class _SuccessAnimState extends State<SuccessAnim>
    with SingleTickerProviderStateMixin {

  late final AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(vsync: this);
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Lottie.asset(
      'assets/animations/success.json',
      controller: _ctrl,
      onLoaded: (comp) {
        _ctrl.duration = comp.duration;
        _ctrl.forward(); // play once
      },
    );
  }
}

Use a single AnimationController and multiple Interval-based CurvedAnimations to sequence different property animations. Each Interval(begin, end) activates only in that fraction of the total duration.

dart
class _StaggeredEntryState extends State<StaggeredEntry>
    with SingleTickerProviderStateMixin {

  late final AnimationController _ctrl;
  late final Animation<double> _fade;
  late final Animation<Offset> _slide;
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    );

    _fade  = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(
      parent: _ctrl, curve: const Interval(0.0, 0.4, curve: Curves.easeIn)));
    _slide = Tween(begin: const Offset(0, 0.3), end: Offset.zero)
        .animate(CurvedAnimation(
      parent: _ctrl, curve: const Interval(0.2, 0.7, curve: Curves.easeOut)));
    _scale = Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(
      parent: _ctrl, curve: const Interval(0.5, 1.0, curve: Curves.elasticOut)));

    _ctrl.forward();
  }

  @override
  void dispose() { _ctrl.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _fade,
      child: SlideTransition(
        position: _slide,
        child: ScaleTransition(scale: _scale, child: widget.child),
      ),
    );
  }
}
Themes & Styling

ColorScheme.fromSeed generates a full Material 3 color palette from a single seed color. Pass the scheme to ThemeData with useMaterial3: true. All Material 3 components automatically use the correct tonal surface and container colors.

dart
ThemeData buildTheme(Brightness brightness) {
  final scheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF7B2FBE),
    brightness: brightness,
  );

  return ThemeData(
    useMaterial3: true,
    colorScheme: scheme,
    fontFamily: 'Inter',
    // Component-level overrides
    appBarTheme: AppBarTheme(
      centerTitle: true,
      backgroundColor: scheme.surfaceContainerHighest,
      foregroundColor: scheme.onSurface,
      elevation: 0,
    ),
    cardTheme: CardTheme(
      elevation: 0,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      color: scheme.surfaceContainerLow,
    ),
    inputDecorationTheme: InputDecorationTheme(
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
      filled: true,
    ),
  );
}

Store the ThemeMode in state (or a provider) and pass it to MaterialApp.themeMode. Set theme for light and darkTheme for dark; Flutter picks the right one automatically.

dart
// Provider
class ThemeNotifier extends ChangeNotifier {
  ThemeMode _mode = ThemeMode.system;
  ThemeMode get mode => _mode;

  void toggle() {
    _mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    notifyListeners();
    PrefsService.setDark(_mode == ThemeMode.dark);
  }
}

// MaterialApp
MaterialApp(
  themeMode: context.watch<ThemeNotifier>().mode,
  theme: buildTheme(Brightness.light),
  darkTheme: buildTheme(Brightness.dark),
  home: const HomePage(),
)

// Toggle button in settings
IconButton(
  icon: Icon(themeMode == ThemeMode.dark ? Icons.light_mode : Icons.dark_mode),
  onPressed: () => context.read<ThemeNotifier>().toggle(),
)

Use Theme.of(context).colorScheme for semantic colors and Theme.of(context).textTheme for typography. Never hardcode hex colors in widget code — always reference the theme so dark mode works automatically.

dart
@override
Widget build(BuildContext context) {
  final scheme = Theme.of(context).colorScheme;
  final text   = Theme.of(context).textTheme;

  return Card(
    color: scheme.surfaceContainerLow,
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('Heading',     style: text.titleLarge),
        Text('Body text',   style: text.bodyMedium),
        Text('Caption',     style: text.labelSmall?.copyWith(
          color: scheme.onSurfaceVariant,
        )),
        Container(
          color: scheme.primaryContainer,
          padding: const EdgeInsets.all(8),
          child: Text('Highlighted',
            style: text.bodySmall?.copyWith(color: scheme.onPrimaryContainer)),
        ),
      ],
    ),
  );
}

Flutter’s ThemeData has a dedicated theme for every Material component. Override at the app level so every instance inherits the style — no need to set style on each individual widget.

dart
ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),

  // Every ElevatedButton uses this shape and padding
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
      textStyle: const TextStyle(fontWeight: FontWeight.w600),
    ),
  ),

  // Chip styling
  chipTheme: ChipThemeData(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    side: BorderSide.none,
  ),

  // All text fields look the same
  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
    contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  ),
)

Use the google_fonts package to load fonts from Google Fonts CDN at runtime, or bundle fonts in assets/fonts/ and declare them in pubspec.yaml for offline use.

dart
// Option 1: google_fonts package
import 'package:google_fonts/google_fonts.dart';

ThemeData(
  textTheme: GoogleFonts.interTextTheme(), // applies Inter to all text
)

// One-off on a widget
Text('Hello', style: GoogleFonts.firaSans(fontWeight: FontWeight.bold));

// Option 2: local fonts (declare in pubspec.yaml)
// flutter:
//   fonts:
//     - family: Poppins
//       fonts:
//         - asset: assets/fonts/Poppins-Regular.ttf
//         - asset: assets/fonts/Poppins-Bold.ttf   weight: 700

ThemeData(
  fontFamily: 'Poppins', // applies globally
)

// Override for specific text
const Text('Display', style: TextStyle(
  fontFamily: 'Poppins',
  fontWeight: FontWeight.w700,
  fontSize: 32,
))

Wrap a sub-tree with Theme(data: Theme.of(context).copyWith(...), child: ...) to override the theme for that widget and all its descendants without affecting the rest of the app.

dart
// Override button color just for the danger zone section
Theme(
  data: Theme.of(context).copyWith(
    colorScheme: Theme.of(context).colorScheme.copyWith(
      primary: Colors.red,
      onPrimary: Colors.white,
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
    ),
  ),
  child: Column(
    children: [
      const Text('Danger Zone', style: TextStyle(color: Colors.red)),
      ElevatedButton(
        onPressed: _deleteAccount,
        child: const Text('Delete Account'),
      ),
    ],
  ),
)

Declare assets in pubspec.yaml, then use Image.asset or AssetImage. For SVGs, use the flutter_svg package. For icons beyond Material, use flutter_launcher_icons or icon packages.

dart
// pubspec.yaml:
// flutter:
//   assets:
//     - assets/images/
//     - assets/icons/

// Raster image (PNG/JPG)
Image.asset(
  'assets/images/logo.png',
  width: 120,
  height: 120,
  fit: BoxFit.contain,
)

// CircleAvatar with local fallback
CircleAvatar(
  backgroundImage: user.avatar != null
      ? NetworkImage(user.avatar!) as ImageProvider
      : const AssetImage('assets/images/default_avatar.png'),
  radius: 24,
)

// SVG (flutter_svg package)
import 'package:flutter_svg/flutter_svg.dart';

SvgPicture.asset(
  'assets/icons/logo.svg',
  colorFilter: ColorFilter.mode(
    Theme.of(context).colorScheme.primary, BlendMode.srcIn),
  width: 40,
)

Flutter’s adaptive approach uses breakpoints (typically 600dp for tablet) combined with LayoutBuilder or MediaQuery. Use Flutter’s adaptive constructors (Switch.adaptive, AlertDialog.adaptive) for platform-native feel.

dart
// Adaptive scaffold: drawer on tablet, bottom nav on phone
class AdaptiveScaffold extends StatelessWidget {
  const AdaptiveScaffold({super.key, required this.body});
  final Widget body;

  @override
  Widget build(BuildContext context) {
    final isWide = MediaQuery.sizeOf(context).width >= 600;

    if (isWide) {
      return Scaffold(
        body: Row(children: [
          NavigationRail(
            destinations: destinations.map((d) =>
              NavigationRailDestination(
                icon: Icon(d.icon), label: Text(d.label))).toList(),
            selectedIndex: 0,
            onDestinationSelected: navigate,
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: body),
        ]),
      );
    }

    return Scaffold(
      body: body,
      bottomNavigationBar: NavigationBar(
        destinations: destinations.map((d) =>
          NavigationDestination(icon: Icon(d.icon), label: d.label)).toList(),
        onDestinationSelected: navigate,
      ),
    );
  }
}
Platform & Device Features

The image_picker package requests the camera/gallery and returns an XFile with the file path. On iOS, add NSCameraUsageDescription and NSPhotoLibraryUsageDescription to Info.plist; on Android, add relevant permissions.

dart
import 'package:image_picker/image_picker.dart';

class _ProfilePhotoState extends State<ProfilePhoto> {
  XFile? _photo;
  final _picker = ImagePicker();

  Future<void> _pickSource(ImageSource source) async {
    final picked = await _picker.pickImage(
      source: source,
      maxWidth: 800,
      maxHeight: 800,
      imageQuality: 85,
    );
    if (picked != null) setState(() => _photo = picked);
  }

  void _showPicker() {
    showModalBottomSheet(context: context, builder: (_) => Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        ListTile(
          leading: const Icon(Icons.camera_alt),
          title: const Text('Take photo'),
          onTap: () { Navigator.pop(context); _pickSource(ImageSource.camera); },
        ),
        ListTile(
          leading: const Icon(Icons.photo_library),
          title: const Text('Choose from gallery'),
          onTap: () { Navigator.pop(context); _pickSource(ImageSource.gallery); },
        ),
      ],
    ));
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _showPicker,
      child: _photo != null
          ? CircleAvatar(backgroundImage: FileImage(File(_photo!.path)), radius: 48)
          : const CircleAvatar(radius: 48, child: Icon(Icons.camera_alt, size: 32)),
    );
  }
}

permission_handler provides a unified API for requesting permissions on Android and iOS. Always check the current status before requesting — re-requesting a permanently denied permission goes to the app settings page.

dart
import 'package:permission_handler/permission_handler.dart';

Future<bool> requestLocationPermission() async {
  var status = await Permission.locationWhenInUse.status;

  if (status.isGranted) return true;

  if (status.isDenied) {
    status = await Permission.locationWhenInUse.request();
    return status.isGranted;
  }

  if (status.isPermanentlyDenied) {
    // Direct user to system settings
    await openAppSettings();
    return false;
  }

  return false;
}

// Request multiple permissions at once
Future<void> requestAll() async {
  final statuses = await [
    Permission.camera,
    Permission.microphone,
    Permission.storage,
  ].request();

  if (statuses[Permission.camera]!.isGranted) startCamera();
}

geolocator wraps platform location APIs. Check service enabled and permission before requesting position. Use getPositionStream for continuous tracking with a low-battery-impact LocationSettings configuration.

dart
import 'package:geolocator/geolocator.dart';

Future<Position> determinePosition() async {
  bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) throw Exception('Location services disabled');

  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      throw Exception('Location permission denied');
    }
  }
  if (permission == LocationPermission.deniedForever) {
    throw Exception('Location permanently denied');
  }

  return Geolocator.getCurrentPosition(
    desiredAccuracy: LocationAccuracy.high,
  );
}

// Continuous stream
StreamSubscription<Position>? _positionSub;

void startTracking() {
  _positionSub = Geolocator.getPositionStream(
    locationSettings: const LocationSettings(
      accuracy: LocationAccuracy.medium,
      distanceFilter: 10, // update every 10 meters
    ),
  ).listen((pos) => setState(() => _position = pos));
}

The url_launcher package calls the platform’s default handler for HTTP URLs, tel:, mailto:, and other URI schemes. Always call canLaunchUrl before launching to handle unsupported schemes gracefully.

dart
import 'package:url_launcher/url_launcher.dart';

Future<void> openLink(String url) async {
  final uri = Uri.parse(url);
  if (!await canLaunchUrl(uri)) throw Exception('Cannot open $url');
  await launchUrl(uri, mode: LaunchMode.externalApplication);
}

// Email with pre-filled fields
Future<void> sendEmail() async {
  final uri = Uri(
    scheme: 'mailto',
    path: 'support@example.com',
    queryParameters: {
      'subject': 'App Feedback',
      'body': 'Hi team,\n\n',
    },
  );
  await launchUrl(uri);
}

// Phone call
Future<void> callPhone(String number) async {
  await launchUrl(Uri.parse('tel:$number'));
}

// In-app browser
Future<void> openInApp(String url) async {
  await launchUrl(Uri.parse(url), mode: LaunchMode.inAppWebView);
}

firebase_messaging handles FCM push notifications. Request permission on iOS. Listen to foreground messages with onMessage; handle background taps with onMessageOpenedApp. Use flutter_local_notifications to show foreground banners.

dart
import 'package:firebase_messaging/firebase_messaging.dart';

// Background message handler (must be top-level function)
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
  print('Background: ${message.notification?.title}');
}

Future<void> initNotifications() async {
  FirebaseMessaging.onBackgroundMessage(_bgHandler);

  // Request permission (iOS)
  await FirebaseMessaging.instance.requestPermission(
    alert: true, badge: true, sound: true,
  );

  // Get FCM token (send to your server)
  final token = await FirebaseMessaging.instance.getToken();
  await api.saveToken(token!);

  // Foreground messages
  FirebaseMessaging.onMessage.listen((RemoteMessage msg) {
    showLocalNotification(
      title: msg.notification?.title ?? '',
      body: msg.notification?.body ?? '',
    );
  });

  // User tapped notification while app was in background
  FirebaseMessaging.onMessageOpenedApp.listen((msg) {
    navigateFromNotification(msg.data);
  });
}

MethodChannel creates a named channel between Dart and the native platform. Call methods from Dart; implement them in Kotlin (Android) or Swift (iOS). Use EventChannel for streams.

dart
// Dart side
const _channel = MethodChannel('com.example.app/battery');

Future<int> getBatteryLevel() async {
  try {
    final level = await _channel.invokeMethod<int>('getBatteryLevel');
    return level ?? -1;
  } on PlatformException catch (e) {
    throw Exception('Failed: ${e.message}');
  }
}

// Android side (MainActivity.kt)
// val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
//                             "com.example.app/battery")
// channel.setMethodCallHandler { call, result ->
//   if (call.method == "getBatteryLevel") {
//     val mgr = getSystemService(BATTERY_SERVICE) as BatteryManager
//     result.success(mgr.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
//   } else result.notImplemented()
// }

// iOS side (AppDelegate.swift)
// let channel = FlutterMethodChannel(name: "com.example.app/battery",
//               binaryMessenger: controller.binaryMessenger)
// channel.setMethodCallHandler { call, result in
//   if call.method == "getBatteryLevel" {
//     UIDevice.current.isBatteryMonitoringEnabled = true
//     result(Int(UIDevice.current.batteryLevel * 100))
//   }
// }

The local_auth package wraps Android BiometricPrompt and iOS LocalAuthentication. Check which biometrics are enrolled, then authenticate. Fall back to PIN/passcode when biometrics aren’t available.

dart
import 'package:local_auth/local_auth.dart';

final _auth = LocalAuthentication();

Future<bool> authenticateWithBiometrics() async {
  // Check device support
  final canCheck = await _auth.canCheckBiometrics;
  final isSupported = await _auth.isDeviceSupported();
  if (!canCheck || !isSupported) return false;

  // Check what's available
  final available = await _auth.getAvailableBiometrics();
  if (available.isEmpty) return false;

  try {
    return await _auth.authenticate(
      localizedReason: 'Authenticate to access your account',
      options: const AuthenticationOptions(
        biometricOnly: false,  // allow PIN fallback
        stickyAuth: true,      // don't cancel on app switch
      ),
    );
  } on PlatformException {
    return false;
  }
}

Dart Isolates are independent threads with no shared memory. Use Isolate.run (Dart 3) for a simple one-shot computation, or compute (Flutter) which wraps the same pattern. For long-lived background work, use isolate_manager or service workers.

dart
// Heavy computation that would freeze the UI
List<Product> parseProducts(String json) {
  final list = jsonDecode(json) as List;
  return list.map((e) => Product.fromJson(e)).toList(); // CPU-heavy
}

// Run in isolate with compute() — Flutter's helper
Future<void> loadProducts() async {
  final jsonString = await http.read(Uri.parse('/api/products'));

  // Offload to background isolate, then get result back
  final products = await compute(parseProducts, jsonString);
  setState(() => _products = products);
}

// Dart 3: Isolate.run for even simpler syntax
Future<void> processImage(File file) async {
  final compressed = await Isolate.run(() {
    final bytes = file.readAsBytesSync();
    return compressImage(bytes, quality: 80); // CPU-heavy
  });
  await uploadBytes(compressed);
}
Testing & Deployment

Unit tests in Flutter use the test package (auto-included in flutter_test). Group related tests with group(); use setUp/tearDown for shared setup. No widget rendering — pure Dart logic only.

dart
// test/cart_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/models/cart_model.dart';

void main() {
  late CartModel cart;

  setUp(() => cart = CartModel());

  group('CartModel', () {
    test('starts empty', () {
      expect(cart.items, isEmpty);
      expect(cart.total, 0.0);
    });

    test('adds item and updates total', () {
      cart.add(Item(id: 1, name: 'Book', price: 29.99));
      expect(cart.items.length, 1);
      expect(cart.total, closeTo(29.99, 0.001));
    });

    test('removes item', () {
      cart.add(Item(id: 1, name: 'Book', price: 29.99));
      cart.remove(1);
      expect(cart.items, isEmpty);
    });

    test('applying discount code reduces total', () {
      cart.add(Item(id: 1, name: 'Book', price: 100.0));
      cart.applyDiscount('SAVE20');
      expect(cart.total, 80.0);
    });
  });
}

Widget tests render widgets in a virtual environment without a physical device. Use tester.pumpWidget to mount, find to locate widgets, and tester.tap/tester.enterText to simulate user interactions. Call tester.pump() after interactions to rebuild.

dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/widgets/counter_widget.dart';

void main() {
  testWidgets('Counter increments on button tap', (tester) async {
    // Mount the widget
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: CounterWidget())),
    );

    // Verify initial state
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    // Simulate user tap
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // trigger rebuild

    // Verify updated state
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);
  });

  testWidgets('Login form validates empty fields', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: LoginPage()));

    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    expect(find.text('Required'), findsWidgets);
  });
}

mocktail (or mockito) generates mock implementations of classes. Stub method return values with when(() => ...).thenAnswer. Verify calls with verify(() => ...).called(1).

dart
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';

// Create a mock
class MockUserRepo extends Mock implements UserRepository {}

void main() {
  late MockUserRepo repo;
  late UserBloc bloc;

  setUp(() {
    repo = MockUserRepo();
    bloc = UserBloc(repository: repo);
  });

  test('loads user on init', () async {
    // Stub the mock
    when(() => repo.getUser(any())).thenAnswer(
      (_) async => const User(id: 1, name: 'Alice'),
    );

    await bloc.loadUser(1);

    // Verify state
    expect(bloc.state, isA<UserLoaded>());
    expect((bloc.state as UserLoaded).user.name, 'Alice');

    // Verify interaction
    verify(() => repo.getUser(1)).called(1);
  });

  test('emits error on failure', () async {
    when(() => repo.getUser(any())).thenThrow(Exception('Not found'));
    await bloc.loadUser(999);
    expect(bloc.state, isA<UserError>());
  });
}

Integration tests live in integration_test/ and use IntegrationTestWidgetsFlutterBinding. They run the full app on a real device or emulator and can automate entire user flows end-to-end.

dart
// integration_test/login_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('Full login flow', (tester) async {
    app.main(); // start the real app
    await tester.pumpAndSettle(); // wait for all animations

    // Find and fill email field
    await tester.enterText(find.byKey(const Key('email-field')), 'user@example.com');
    await tester.enterText(find.byKey(const Key('password-field')), 'password123');
    await tester.tap(find.byKey(const Key('login-button')));
    await tester.pumpAndSettle(); // wait for navigation

    // Verify we're on the home page
    expect(find.text('Welcome back!'), findsOneWidget);
  });
}

// Run: flutter test integration_test/login_flow_test.dart

Run in profile mode (flutter run --profile) to enable DevTools. The Performance view shows frame build times, identifies jank (frames >16ms), and shows which widget subtrees are rebuilt. The CPU profiler identifies slow Dart code.

bash
# Run in profile mode
flutter run --profile

# Open DevTools in browser
dart devtools

# From terminal — opens and connects automatically
flutter run --profile --devtools
dart
// Add custom timeline events to trace slow code
import 'dart:developer';

Future<void> heavyOperation() async {
  Timeline.startSync('MyHeavyOp');
  // ...work...
  Timeline.finishSync();
}

// Find rebuild hot spots — add to slow widgets:
class ExpensiveWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    debugPrint('ExpensiveWidget rebuilt'); // watch in DevTools
    return const SomethingExpensive();
  }
}

Sign the app with a keystore configured in android/app/build.gradle. Build an App Bundle (AAB) for Play Store — it’s smaller than an APK because Google generates device-specific APKs. Build an APK for direct distribution.

bash
# Generate keystore (once)
keytool -genkey -v -keystore upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias upload

# Build App Bundle (Play Store preferred)
flutter build appbundle --release

# Build APK (direct install)
flutter build apk --release

# Build split APKs by ABI (smaller downloads)
flutter build apk --split-per-abi --release

# Output locations:
# AAB: build/app/outputs/bundle/release/app-release.aab
# APK: build/app/outputs/flutter-apk/app-release.apk

Flutter flavors map to Android build variants and iOS schemes. Each flavor has a separate main_*.dart entry point with environment-specific config (API URLs, Firebase projects, feature flags).

dart
// lib/config/environment.dart
enum Environment { dev, staging, prod }

class Env {
  static late Environment _current;

  static void init(Environment env) => _current = env;

  static String get apiUrl => switch (_current) {
    Environment.dev     => 'https://dev.api.example.com',
    Environment.staging => 'https://staging.api.example.com',
    Environment.prod    => 'https://api.example.com',
  };

  static bool get showDevTools => _current != Environment.prod;
}

// lib/main_dev.dart
void main() {
  Env.init(Environment.dev);
  runApp(const MyApp());
}

// lib/main_prod.dart
void main() {
  Env.init(Environment.prod);
  runApp(const MyApp());
}

// Run: flutter run -t lib/main_dev.dart --flavor dev
// Build: flutter build apk -t lib/main_prod.dart --flavor prod

Pass --obfuscate and --split-debug-info to the build command. The debug symbols file lets you de-obfuscate crash stack traces locally. Store the symbols file — you’ll need it to read crash reports.

bash
# Build with obfuscation
flutter build apk --release \
  --obfuscate \
  --split-debug-info=./debug-info/

# AAB for Play Store
flutter build appbundle --release \
  --obfuscate \
  --split-debug-info=./debug-info/

# De-obfuscate a crash stack trace
flutter symbolize \
  --input=crash_stack.txt \
  --debug-info=./debug-info/app.android-arm64.symbols

# Keep the debug-info/ directory — commit it to a private repo
# or store in CI artifacts. Without it, crash traces are unreadable.

Run flutter build web to produce a static site in build/web/. Use the canvaskit renderer for pixel-perfect rendering (larger, slower initial load) or html renderer for faster startup. Deploy to Firebase Hosting, Netlify, Vercel, or any static host.

bash
# Build for web with canvaskit renderer
flutter build web --release --web-renderer canvaskit

# Build with html renderer (faster initial load, less fidelity)
flutter build web --release --web-renderer html

# Auto-select based on device (default)
flutter build web --release --web-renderer auto

# Deploy to Firebase Hosting
firebase deploy --only hosting

# Or serve locally to test the build
cd build/web && python3 -m http.server 8000

# Set base href for sub-path deployment
# flutter build web --base-href /myapp/

Create a GitHub Actions workflow that installs Flutter, runs analysis and tests, then builds the app. Cache the Flutter installation and pub packages to speed up subsequent runs.

yaml
# .github/workflows/ci.yml
name: Flutter CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'
          channel: 'stable'
          cache: true        # cache Flutter SDK

      - name: Install dependencies
        run: flutter pub get

      - name: Analyze
        run: flutter analyze --fatal-infos

      - name: Test
        run: flutter test --coverage

      - name: Build APK
        run: flutter build apk --release
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}

Add firebase_crashlytics to your project. Set it as Flutter’s error handler and Dart’s uncaught exception zone. In production, all uncaught errors are automatically uploaded with device info, app version, and stack traces.

dart
import 'package:firebase_crashlytics/firebase_crashlytics.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Catch Flutter framework errors
  FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Catch async errors outside Flutter (e.g., isolates)
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

// Log non-fatal errors manually
try {
  await riskyOperation();
} catch (e, stack) {
  await FirebaseCrashlytics.instance.recordError(e, stack, fatal: false);
}

// Add custom context to crash reports
FirebaseCrashlytics.instance.setCustomKey('user_id', user.id);
FirebaseCrashlytics.instance.log('User navigated to checkout');

A production Flutter app layers clean architecture: presentation (widgets, pages), domain (use cases, models), and data (repositories, API clients). Use feature-based folder structure, Riverpod for state, go_router for navigation, and Dio for networking.

dart
// Architecture layers:
// lib/
//   core/
//     network/        — Dio client, interceptors
//     storage/        — SharedPrefs, SQLite
//     errors/         — AppException, Failure
//   features/
//     auth/
//       data/
//         auth_repository_impl.dart
//         auth_api.dart
//       domain/
//         auth_repository.dart   (abstract)
//         login_usecase.dart
//       presentation/
//         auth_providers.dart    (Riverpod)
//         login_page.dart
//         login_form.dart
//     home/
//       ...

// Example: clean use case
class LoginUseCase {
  const LoginUseCase(this._repository);
  final AuthRepository _repository;

  Future<User> call(String email, String password) async {
    if (!email.contains('@')) throw const AppException('Invalid email');
    return _repository.login(email, password);
  }
}

// Riverpod provider wires it together
final loginProvider = AsyncNotifierProvider<LoginNotifier, void>(() {
  return LoginNotifier(
    loginUseCase: LoginUseCase(ref.watch(authRepositoryProvider)),
  );
});

// Router with auth guard
final routerProvider = Provider((ref) {
  final isAuth = ref.watch(authStateProvider);
  return GoRouter(
    redirect: (_, state) {
      if (!isAuth && state.matchedLocation != '/login') return '/login';
      if (isAuth && state.matchedLocation == '/login') return '/home';
      return null;
    },
    routes: [ /* ... */ ],
  );
});
Prev
Next
Drag
Map
HTML Snippets Powered By : XYZScripts.com
Select the fields to be shown. Others will be hidden. Drag and drop to rearrange the order.
  • Image
  • SKU
  • Rating
  • Price
  • Stock
  • Availability
  • Add to cart
  • Description
  • Content
  • Weight
  • Dimensions
  • Additional information
Click outside to hide the comparison bar
Compare