Plan
The steps an assistant set itself, ticked off as it works.
// The plan demo: the page's picture and what runs live.
import 'package:flutter/widgets.dart';
import 'ui/plan.dart';
class PlanDemo extends StatelessWidget {
const PlanDemo({super.key});
@override
Widget build(BuildContext context) => const SizedBox(
width: 460,
child: Plan(
title: 'Move the orders table',
steps: [
PlanStep('Back up the table', status: PlanStepStatus.done),
PlanStep('Make the new table', status: PlanStepStatus.done),
PlanStep('Copy the rows across', status: PlanStepStatus.active, detail: '1.2M of 2M rows'),
PlanStep('Switch reads over'),
PlanStep('Drop the old table'),
],
),
);
}
Add it
In your Flutter project:
ashlar add plan
It writes theme.dart, palette.dart, shimmer.dart, spinner.dart and plan.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs shimmer, spinner and ashlar_icons, which come with it.
API
Read from the source above: each parameter with its type, its default, and what it is for.
PlanStepStatusenum
Where a step of a plan has got to.
| Value | What it is for |
|---|---|
pending | |
active | |
done | |
skipped | |
failed |
PlanStepclass
One step of a plan: what it is, where it has got to, and a line more if it needs one.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
titlepositional |
String |
||
status |
PlanStepStatus |
PlanStepStatus.pending |
|
detail |
String? |
A line under the title: what was found, why it failed. |
Planclass
The steps an assistant set itself, ticked off as it works: done ones ticked, the one it is on turning and shimmering, those to come as empty rings, and how many are done at the top.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
stepsrequired |
List<PlanStep> |
||
title |
String? |
What the plan is for, above the steps. |
Source
What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add
will not write over it.
lib/ui/plan.dart 229 lines
// Ashlar UI: Plan. Copied into your app by `ashlar add plan`; it is yours to change.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import 'shimmer.dart';
import 'spinner.dart';
import 'theme.dart';
/// Where a step of a plan has got to.
enum PlanStepStatus { pending, active, done, skipped, failed }
/// One step of a plan: what it is, where it has got to, and a line more if it needs one.
class PlanStep {
const PlanStep(
this.title, {
this.status = PlanStepStatus.pending,
this.detail,
});
final String title;
final PlanStepStatus status;
/// A line under the title: what was found, why it failed.
final String? detail;
}
/// The steps an assistant set itself, ticked off as it works: done ones ticked, the one it
/// is on turning and shimmering, those to come as empty rings, and how many are done at
/// the top.
///
/// ```dart
/// Plan(
/// title: 'Move the orders table',
/// steps: [
/// PlanStep('Back up the table', status: PlanStepStatus.done),
/// PlanStep('Copy the rows across', status: PlanStepStatus.active, detail: '1.2M of 2M'),
/// PlanStep('Switch reads over'),
/// ],
/// )
/// ```
class Plan extends StatelessWidget {
const Plan({super.key, required this.steps, this.title});
final List<PlanStep> steps;
/// What the plan is for, above the steps.
final String? title;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final done = steps.where((s) => s.status == PlanStepStatus.done).length;
final count = '$done of ${steps.length} done';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: c.background,
borderRadius: BorderRadius.circular(t.radiusLg),
border: Border.all(color: c.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title ?? 'Plan',
style: t.text.sm.copyWith(
color: c.foreground,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Text(
count,
style: t.text.xs.copyWith(
color: c.mutedForeground,
height: 20 / 12,
),
),
],
),
const SizedBox(height: 10),
// How far along, as a thin bar: read out as the count above.
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: SizedBox(
height: 3,
child: Stack(
children: [
Positioned.fill(child: ColoredBox(color: c.muted)),
FractionallySizedBox(
widthFactor: steps.isEmpty ? 0 : done / steps.length,
heightFactor: 1,
alignment: AlignmentDirectional.centerStart,
child: ColoredBox(color: c.primary),
),
],
),
),
),
),
const SizedBox(height: 6),
for (final s in steps) _Step(step: s),
],
),
);
}
}
class _Step extends StatelessWidget {
const _Step({required this.step});
final PlanStep step;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final (Widget mark, String said) = switch (step.status) {
PlanStepStatus.pending => (
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: c.input, width: 1.5),
),
),
'to do',
),
PlanStepStatus.active => (
Spinner(size: 14, color: c.foreground, semanticLabel: ''),
'in progress',
),
PlanStepStatus.done => (
Container(
width: 16,
height: 16,
decoration: BoxDecoration(shape: BoxShape.circle, color: c.primary),
child: AshlarIcon(
AshlarIconsRoundedStroke.check,
size: 11,
color: c.primaryForeground,
),
),
'done',
),
PlanStepStatus.skipped => (
AshlarIcon(
AshlarIconsRoundedStroke.circleMinus,
size: 16,
color: c.mutedForeground,
),
'skipped',
),
PlanStepStatus.failed => (
AshlarIcon(
AshlarIconsRoundedStroke.circleX,
size: 16,
color: c.destructive,
),
'failed',
),
};
final quiet =
step.status == PlanStepStatus.pending ||
step.status == PlanStepStatus.skipped;
final title = Text(
step.title,
style: t.text.sm.copyWith(
color: quiet ? c.mutedForeground : c.foreground,
fontWeight: step.status == PlanStepStatus.active
? FontWeight.w500
: null,
decoration: step.status == PlanStepStatus.skipped
? TextDecoration.lineThrough
: null,
decorationColor: c.mutedForeground,
),
);
return MergeSemantics(
child: Semantics(
value: said,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 16,
height: 20,
child: Center(child: ExcludeSemantics(child: mark)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (step.status == PlanStepStatus.active)
Shimmer(child: title)
else
title,
if (step.detail != null)
Text(
step.detail!,
style: t.text.xs.copyWith(
color: step.status == PlanStepStatus.failed
? c.destructive
: c.mutedForeground,
),
),
],
),
),
],
),
),
),
);
}
}