metro_map 0.1.2
metro_map: ^0.1.2 copied to clipboard
Draws linked items as a metro map: parallel branches share a row, merges are visible, and everything flows to the finish. Vertical or horizontal.

🚇 Metro Map #
metro_map draws linked items as a metro map for Flutter. The item that ends last is the finish; every other item sits on a row by how many links it is from the finish, so branches that run in parallel share a row and you can see exactly where they merge. Depends on nothing but Flutter.

📋 Features #
- Branches and merges at a glance: items the same number of links from the finish share a row, so parallel work lines up and every merge is a visible junction.
- Any data:
MetroMap<T>takes your own model and reads its id, end time, title and colour through callbacks — no wrapper classes. - Vertical or horizontal: top to bottom for phones, left to right for wide screens, one flag.
- Progress in the lines: a line takes the colour of the station it leads to and stays faded until that station is done; the finish gets its own flag.
- Fewer crossings: inside a row, stations are ordered by the position of their neighbours above.
- Tappable stations:
onTapgives you the item back. - Headless core:
layoutMetrois pure Dart — unit-test layouts or draw them with your own renderer.
🚀 Installation #
Add the dependency in your pubspec.yaml:
dependencies:
metro_map: ^latest_version
Then run:
flutter pub get
📖 Usage #
1. Describe your items #
The type parameter is whatever you want to show on a station — a model, a record, anything. Each item needs an int id and an end time:
import 'package:metro_map/metro_map.dart';
typedef Task = ({int id, String title, DateTime due, Color color, bool done});
2. List the links #
Links are pairs of ids. They are undirected: (1, 2) and (2, 1) are the same line. Links to ids that are not among the items, and links from an item to itself, are ignored:
const links = [(1, 2), (1, 3), (2, 4), (3, 4)];
3. Show it #
MetroMap<Task>(
items: tasks,
links: links,
idOf: (t) => t.id,
endOf: (t) => t.due, // the latest one becomes the finish
titleOf: (t) => t.title,
subtitleOf: (t) => DateFormat.MMMd().format(t.due),
colorOf: (t) => t.color, // station and the line leading to it
isDone: (t) => t.done, // filled station, solid line
onTap: (t) => openTask(t),
)
The vertical map takes the width it is given and sizes its height to the rows, so put it in a vertical scroll view.
4. Go horizontal #
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MetroMap<Task>(
horizontal: true,
// ...the same callbacks
),
)
A horizontal map sizes its width to the rows, so it goes in a horizontal scroll view.
5. Style it #
MetroMap<Task>(
// ...
finishColor: Colors.deepPurple, // default: colorScheme.primary
finishIcon: Icons.rocket_launch, // default: Icons.flag_rounded
mutedColor: Colors.grey, // subtitles and done labels; default: hintColor
titleStyle: const TextStyle(fontFamily: 'Inter'),
)
Full example #
import 'package:flutter/material.dart';
import 'package:metro_map/metro_map.dart';
void main() => runApp(const ExampleApp());
/// A task of a product launch: three branches that meet before the release.
typedef Task = ({int id, String title, int day, Status status});
enum Status {
todo(Color(0xFFE5AC00)),
inProgress(Color(0xFF2F9FD6)),
done(Color(0xFF2FA866));
const Status(this.color);
final Color color;
}
const tasks = <Task>[
(id: 1, title: 'Plan', day: 5, status: Status.done),
(id: 2, title: 'Copy', day: 6, status: Status.done),
(id: 3, title: 'Design', day: 6, status: Status.done),
(id: 4, title: 'Domain', day: 6, status: Status.done),
(id: 5, title: 'Localization', day: 8, status: Status.inProgress),
(id: 6, title: 'Screenshots', day: 8, status: Status.inProgress),
(id: 7, title: 'DNS', day: 7, status: Status.done),
(id: 8, title: 'Code review', day: 9, status: Status.todo),
(id: 9, title: 'Release', day: 10, status: Status.todo),
(id: 10, title: 'Announce', day: 11, status: Status.todo),
];
/// Plan splits into three branches that meet at the code review.
const links = [
(1, 2),
(1, 3),
(1, 4),
(2, 5),
(3, 6),
(4, 7),
(5, 8),
(6, 8),
(7, 8),
(8, 9),
(9, 10),
];
class ExampleApp extends StatefulWidget {
const ExampleApp({super.key});
@override
State<ExampleApp> createState() => _ExampleAppState();
}
class _ExampleAppState extends State<ExampleApp> {
var _horizontal = true;
var _tapped = 'Tap a station';
@override
Widget build(BuildContext context) {
final map = MetroMap<Task>(
items: tasks,
links: links,
horizontal: _horizontal,
idOf: (t) => t.id,
endOf: (t) => DateTime(2026, 10, t.day),
titleOf: (t) => t.title,
subtitleOf: (t) => 'Oct ${t.day}',
colorOf: (t) => t.status.color,
isDone: (t) => t.status == Status.done,
finishColor: const Color(0xFF8E7CF3),
onTap: (t) => setState(() => _tapped = t.title),
);
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF141314),
),
home: Scaffold(
appBar: AppBar(
title: const Text('metro_map'),
centerTitle: true,
backgroundColor: Colors.transparent,
actions: [
IconButton(
tooltip: 'Rotate',
icon: Icon(_horizontal ? Icons.swap_vert : Icons.swap_horiz),
onPressed: () => setState(() => _horizontal = !_horizontal),
),
],
),
body: Column(
children: [
Text(_tapped, style: const TextStyle(color: Colors.white54)),
Expanded(
child: Center(
child: SingleChildScrollView(
scrollDirection: _horizontal
? Axis.horizontal
: Axis.vertical,
padding: const EdgeInsets.all(32),
child: _horizontal
? map
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: map,
),
),
),
),
],
),
),
);
}
}
⚙️ How the layout works #
- the finish is the item that ends last; on a tie, the one with the higher id;
- every item's row is its distance in links from the finish, found by a breadth-first search, so the finish is the last row and the start is the first;
- items that cannot reach the finish go on the top row;
- inside a row, stations are sorted by the mean position of their neighbours in the rows above, then by end time, which keeps crossings down;
- each link is drawn once, from the upper station to the lower one, as a smooth curve in the colour of the lower station.
Because a row is the shortest distance to the finish, a branch that skips ahead (a link straight from an early item to a late one) pulls its start closer to the finish.
📚 API Reference #
-
MetroMap<T>— the widgetitems,linksidOf(item),endOf(item),titleOf(item),subtitleOf(item),colorOf(item),isDone(item)onTap(item),horizontalfinishColor,finishIcon,mutedColor,titleStyle
-
layoutMetro({ends, links})— the headless layout, returns aMetroLayout -
MetroLayout—stations,edges(upper, lower),finishId,rows -
MetroStation—id,row(0 is the top),x(0..1 across the row)
