Tech Blog

Building a Port-Scoped Scooter Selection UI With a BottomSheet — Flutter's DraggableScrollableSheet + FutureBuilder Combo

by Tech Writer
Flutter Dart BottomSheet UI/UX Riverpod

What You’ll Learn

  • A Flutter implementation pattern for showing a “scooter list” child surface after a port tap
  • How to pick initialChildSize when combining DraggableScrollableSheet with showModalBottomSheet
  • A minimal way to handle “loading / error / empty / result” — four states — with FutureBuilder for an API-backed list
  • How to extract a shared widget so the home screen and destination screen share the same look

Target Audience

  • Flutter developers implementing post-tap child surfaces for POIs on a map
  • People who want to grow BottomSheet from “a quick modal” into “a business-grade selection UI”
  • Developers who use Riverpod globally but want to write local async fetches with plain FutureBuilder

Environment

ItemVersion
Flutter3.x (Material 3)
flutter_map7.x
flutter_riverpod2.x
dio5.x

Series article (in active development) — This article is part of Building Street-Level E-Scooter Sharing From Scratch to Understand It — Design, Implementation, and Operations Series. The project is ongoing; new articles and design notes are added continuously. The series motivation, technology stack, screen-ID glossary, and the full article index are collected at the link above.

Introduction

On the S02 home screen of the e-scooter sharing app, tapping a port pin used to show this sheet:

  • Port name
  • One line: “Scooters are available now” or “No scooters available now”
  • Two cards: “Available N” and “Empty slots M”

You could see how many were available, but you couldn’t pick which scooter. The child surface needed to handle “tap pin → see scooter list → tap scooter to navigate” all in one place.

S02 port sheet after the change — summary cards + scooter list


Why DraggableScrollableSheet

Three options:

  1. showDialog full-screen dialog
  2. push to a new screen
  3. showModalBottomSheet + DraggableScrollableSheet

(1) loses the map context. The feeling of “near this port” disappears.

(2) is a heavy transition. Adds a back-button trip.

(3) keeps the map in view while letting the user scroll through the scooter list. UX-wise the most natural.

The reason we chose DraggableScrollableSheet: the user can drag up to expand the sheet. On ports with many scooters, the user can pull the sheet up to see all of them.


Calling showModalBottomSheet

void _showPortSheet(PortSummary port) {
  final api = DiscoveryApi(ref.read(apiClientProvider));
  showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true, // ← without this, the sheet stops at half-height
    builder: (sheetContext) {
      return _PortDetailSheet(
        port: port,
        api: api,
        onScooterSelected: (scooter) {
          Navigator.of(sheetContext).pop();
          final scooterId = Uri.encodeComponent(scooter.scooterId);
          context.push('/destination?scooterId=$scooterId');
        },
      );
    },
  );
}

Notes:

  • Forgetting isScrollControlled: true breaks DraggableScrollableSheet’s height control
  • Distinguish sheetContext from the outer context. Use Navigator.of(sheetContext).pop() to close the sheet, then context.push(...) to navigate

DraggableScrollableSheet’s initialChildSize

class _PortDetailSheet extends StatefulWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return DraggableScrollableSheet(
      expand: false,
      initialChildSize: 0.55,
      minChildSize: 0.3,
      maxChildSize: 0.9,
      builder: (context, scrollController) {
        return SingleChildScrollView(
          controller: scrollController,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(port.name, style: Theme.of(context).textTheme.titleLarge),
                // summary cards + scooter list + footer
              ],
            ),
          ),
        );
      },
    );
  }
}

initialChildSize: 0.55 means “initial height is 55% of the screen.” We landed on this value because:

  • 0.4 shows only 1〜2 scooters and forces scrolling
  • 0.7 leaves the map too small, weakening the “this port right here” feeling
  • 0.55 shows the summary cards + ~3 scooters and keeps the map half-visible — the right balance

minChildSize: 0.3 keeps at least the minimum info when the user drags down to shrink. maxChildSize: 0.9 lets the user drag up to nearly hide the map and see the full scooter list.

The destination screen (return-port selection) shares the same initialChildSize: 0.55, so both enabled and disabled cases appear at the same size.

"Return here" enabled on destination

"Return here" disabled on destination (full port)

The disabled branch is port.availableEmptySlots == 0ElevatedButton.onPressed: null. “Can’t ride / can’t return” is consistently expressed as “tap-or-not” across screens, which becomes a rule that holds horizontally.


FutureBuilder for the Scooter List

The scooter list needs an API call, so we handle four states: loading / error / empty / result.

class _PortDetailSheetState extends State<_PortDetailSheet> {
  late Future<List<ScooterSummary>> _scootersFuture;

  @override
  void initState() {
    super.initState();
    _scootersFuture = widget.api.getScootersInPort(widget.port.portId);
  }

  void _retry() {
    setState(() {
      _scootersFuture = widget.api.getScootersInPort(widget.port.portId);
    });
  }

  // inside build
  FutureBuilder<List<ScooterSummary>>(
    future: _scootersFuture,
    builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.waiting) {
        return const Padding(
          padding: EdgeInsets.symmetric(vertical: 16),
          child: Center(child: CircularProgressIndicator()),
        );
      }
      if (snapshot.hasError) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Failed to load scooters.',
                style: TextStyle(color: Colors.red.shade700)),
            const SizedBox(height: 8),
            OutlinedButton(onPressed: _retry, child: const Text('Retry')),
          ],
        );
      }
      final scooters = snapshot.data ?? const <ScooterSummary>[];
      if (scooters.isEmpty) {
        return const Text('No scooters registered at this port.');
      }
      return Column(
        children: scooters.map((s) => _ScooterListTile(
          scooter: s,
          onTap: s.isAvailable ? () => widget.onScooterSelected(s) : null,
        )).toList(),
      );
    },
  )

Points:

  • Build _scootersFuture once in initState. Calling widget.api.getScootersInPort(...) directly in build would refetch on every setState
  • “Retry on error” simply rebuilds _scootersFuture via setState
  • Branch separately for empty arrays. Use snapshot.data ?? const <ScooterSummary>[] for null safety

Riverpod’s FutureProvider is an option, but for “a Future that lives only while this sheet is open,” a local FutureBuilder is simpler.


Scooter Tile (Only AVAILABLE Is Tappable)

class _ScooterListTile extends StatelessWidget {
  const _ScooterListTile({required this.scooter, required this.onTap});

  final ScooterSummary scooter;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final disabled = onTap == null;
    final statusColor = switch (scooter.status.toUpperCase()) {
      'AVAILABLE' => Colors.green.shade700,
      'IN_USE' => Colors.orange.shade700,
      'MAINTENANCE' => Colors.grey.shade600,
      _ => Colors.grey.shade600,
    };
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4),
      child: ListTile(
        onTap: onTap,
        leading: Icon(Icons.electric_scooter,
            color: disabled ? Colors.grey : statusColor),
        title: Text(scooter.scooterId,
            style: TextStyle(color: disabled ? Colors.grey : null)),
        subtitle: Row(/* battery level + status badge */),
        trailing: disabled ? null : const Icon(Icons.chevron_right),
      ),
    );
  }
}

Setting onTap to null makes the entire ListTile inactive. This is Material standard behavior, no need to implement a disabled prop.

Dart 3’s switch expression lets you map state → color declaratively, which feels clean.


Extracting the Shared Widget

We ended up wanting the same “available / empty slots” cards on both the home and destination screens. Initially each screen held a private _PortStatCard. We extracted it into widgets/port_stat_card.dart as a shared widget.

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

  final String label;
  final String value;
  final Color accent;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: accent.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
          const SizedBox(height: 4),
          Text(value, style: Theme.of(context).textTheme.titleMedium),
        ],
      ),
    );
  }
}

Only accent color is passed in, with background and text colors derived from it. Pass Colors.green.shade700 for the available card, Colors.indigo.shade700 for the empty-slot card, and one widget covers both looks.


Pitfall: The replace_all Refactor Trap

When renaming _PortStatCard to PortStatCard, an IDE “replace all” only replaced the constructor calls and left the class definition (class _PortStatCard) intact — Dart syntax error.

class _PortStatCard extends StatelessWidget {  // ← still underscore-prefixed
  const PortStatCard({                          // ← but constructor is unprefixed
    // ...
  });
}

This happened because “the string _PortStatCard(” was the replace target (the class declaration class _PortStatCard isn’t followed by a ().

Fix: create the shared widget in a new file, then delete the old class entirely. Don’t trust replace_all too much.


Lessons

  • Post-tap child surfaces over a map are most natural as showModalBottomSheet + DraggableScrollableSheet
  • initialChildSize: 0.55 is the heuristic balance for summary + several list items + map still half visible
  • Local async fetches are fine with FutureBuilder. No need to reach for Riverpod’s FutureProvider
  • Shared cards that only receive accent and derive other colors reuse easily across screens

Even a simple child surface has plenty of room for craft when you want to finish business actions without breaking the map UX flow.

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.