Blocks
Whole screens made of Ashlar UI components: a dashboard, sidebars, logins and signups. Each lands in lib/blocks beside your components, its imports pointing at them, to change as you like.
ashlar add --blocks
Dashboard
An app home: navigation, figures, a chart over time and a table.
dashboard-01
An app home: sidebar, header, four figures, a chart over time and a table.
ashlar add dashboard-01
lib/blocks/dashboard_01.dart 638 lines
// Ashlar UI block: dashboard-01. Copied into your app by `ashlar add dashboard-01`; it is
// yours to change. An app's home: a sidebar, a header, four figures, a chart over time and
// a table of documents.
//
// The figures, chart and rows are sample data, there to be replaced with yours.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/badge.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/chart.dart';
import '../ui/data_table.dart';
import '../ui/dropdown_menu.dart';
import '../ui/popover.dart';
import '../ui/select.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/tabs.dart';
import '../ui/theme.dart';
import '../ui/toggle_group.dart';
/// A dashboard: navigation down the side, the page set into it as a card.
class Dashboard01 extends StatelessWidget {
const Dashboard01({super.key});
@override
Widget build(BuildContext context) => SidebarLayout(
variant: SidebarVariant.inset,
collapse: SidebarCollapse.offcanvas,
header: const _Brand(),
groups: [
const SidebarGroup.custom(_QuickCreate()),
SidebarGroup([
SidebarItem(
'Dashboard',
icon: AshlarIconsRoundedStroke.layoutDashboard,
active: true,
onPressed: () {},
),
SidebarItem(
'Lifecycle',
icon: AshlarIconsRoundedStroke.listChecks,
onPressed: () {},
),
SidebarItem(
'Analytics',
icon: AshlarIconsRoundedStroke.chartBar,
onPressed: () {},
),
SidebarItem(
'Projects',
icon: AshlarIconsRoundedStroke.folder,
onPressed: () {},
),
SidebarItem(
'Team',
icon: AshlarIconsRoundedStroke.users,
onPressed: () {},
),
]),
SidebarGroup(label: 'Documents', [
for (final (label, icon) in [
('Data Library', AshlarIconsRoundedStroke.database),
('Reports', AshlarIconsRoundedStroke.fileText),
('Word Assistant', AshlarIconsRoundedStroke.file),
])
SidebarItem(
label,
icon: icon,
onPressed: () {},
action: SidebarAction(
icon: AshlarIconsRoundedStroke.ellipsis,
label: 'More for $label',
onPressed: () {},
),
),
]),
SidebarGroup(atEnd: true, [
SidebarItem(
'Settings',
icon: AshlarIconsRoundedStroke.settings,
onPressed: () {},
),
SidebarItem(
'Get Help',
icon: AshlarIconsRoundedStroke.circleQuestionMark,
onPressed: () {},
),
SidebarItem(
'Search',
icon: AshlarIconsRoundedStroke.search,
onPressed: () {},
),
]),
],
footer: const _User(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Header(title: 'Documents'),
const Separator(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
_Figures(),
SizedBox(height: 24),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: _Visitors(),
),
SizedBox(height: 24),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: _Documents(),
),
],
),
),
),
],
),
);
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
const AshlarIcon(AshlarIconsRoundedStroke.command, size: 20),
const SizedBox(width: 8),
Text(
'Acme Inc.',
style: t.text.base.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
}
class _QuickCreate extends StatelessWidget {
const _QuickCreate();
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: Button(
expand: true,
leading: const AshlarIcon(AshlarIconsRoundedStroke.circlePlus),
onPressed: () {},
child: const Text('Quick Create'),
),
),
const SizedBox(width: 8),
Button(
variant: ButtonVariant.outline,
size: ButtonSize.icon,
semanticLabel: 'Inbox',
onPressed: () {},
child: const AshlarIcon(AshlarIconsRoundedStroke.inbox),
),
],
);
}
class _User extends StatelessWidget {
const _User();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.circleUser),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 12),
Text(
title,
style: t.text.base.copyWith(fontWeight: FontWeight.w500),
),
],
),
),
);
}
}
/// Four figures, each with how it moved: four across, two, or one, as there is room.
class _Figures extends StatelessWidget {
const _Figures();
static const _figures = [
(
'Total Revenue',
r'$1,250.00',
'+12.5%',
true,
'Trending up this month',
'Visitors for the last 6 months',
),
(
'New Customers',
'1,234',
'-20%',
false,
'Down 20% this period',
'Acquisition needs attention',
),
(
'Active Accounts',
'45,678',
'+12.5%',
true,
'Strong user retention',
'Engagement exceeds targets',
),
(
'Growth Rate',
'4.5%',
'+4.5%',
true,
'Steady performance increase',
'Meets growth projections',
),
];
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return LayoutBuilder(
builder: (context, box) {
final across = box.maxWidth >= 1100 ? 4 : (box.maxWidth >= 640 ? 2 : 1);
const gap = 16.0;
final width = (box.maxWidth - 48 - gap * (across - 1)) / across;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final (label, value, change, up, line, note) in _figures)
SizedBox(
width: width,
child: Card(
children: [
CardHeader(
description: Text(label),
title: Text(value, style: t.text.xxl),
action: Badge(
variant: BadgeVariant.outline,
leading: AshlarIcon(
up
? AshlarIconsRoundedStroke.trendingUp
: AshlarIconsRoundedStroke.trendingDown,
),
child: Text(change),
),
),
CardFooter(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
line,
style: t.text.sm.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 6),
AshlarIcon(
up
? AshlarIconsRoundedStroke.trendingUp
: AshlarIconsRoundedStroke
.trendingDown,
),
],
),
const SizedBox(height: 4),
Text(
note,
style: t.text.sm.copyWith(
color: c.mutedForeground,
),
),
],
),
),
],
),
],
),
),
],
),
);
},
);
}
}
/// Visitors over time, on desktop and mobile, for a range picked at the top.
class _Visitors extends StatefulWidget {
const _Visitors();
@override
State<_Visitors> createState() => _VisitorsState();
}
class _VisitorsState extends State<_Visitors> {
var _range = 90;
static const _ranges = [
(90, 'Last 3 months'),
(30, 'Last 30 days'),
(7, 'Last 7 days'),
];
static const _months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', //
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
// Sample visits: a day's count for each of the last 91 days, ending 30 June.
static int _desktop(int day) =>
160 + (day * 53) % 240 + (day % 7 == 5 ? 90 : 0);
static int _mobile(int day) =>
110 + (day * 37) % 200 + (day % 7 == 6 ? 60 : 0);
@override
Widget build(BuildContext context) {
final end = DateTime(2026, 6, 30);
// A point a week for three months, every other day for 30 days, daily for 7.
final step = switch (_range) {
90 => 7,
30 => 2,
_ => 1,
};
final days = [
for (var back = _range - 1; back >= 0; back -= step)
end.subtract(Duration(days: back)),
];
int sum(int Function(int) count, DateTime d) =>
[for (var i = 0; i < step; i++) count(d.day + d.month * 31 + i)]
.fold(0, (a, b) => a + b);
final narrow = MediaQuery.sizeOf(context).width < 640;
return Card(
children: [
CardHeader(
title: const Text('Total Visitors'),
description: Text(switch (_range) {
90 => 'Total for the last 3 months',
30 => 'Total for the last 30 days',
_ => 'Total for the last 7 days',
}),
// Buttons where there is room, a select where there is not.
action: narrow
? SizedBox(
width: 150,
child: Select<int>(
value: _range,
size: SelectSize.sm,
semanticLabel: 'Range',
options: [for (final (v, l) in _ranges) SelectOption(v, l)],
onChanged: (v) => setState(() => _range = v),
),
)
: ToggleGroup<int>(
values: {_range},
semanticLabel: 'Range',
onChanged: (v) {
if (v.isNotEmpty) setState(() => _range = v.first);
},
items: [
for (final (v, l) in _ranges) ToggleGroupItem(v, Text(l)),
],
),
),
CardContent(
child: AreaChart(
semanticLabel: 'Visitors by day',
stacked: true,
labels: [for (final d in days) '${_months[d.month - 1]} ${d.day}'],
series: [
ChartSeries('Desktop', [
for (final d in days) sum(_desktop, d).toDouble(),
]),
ChartSeries('Mobile', [
for (final d in days) sum(_mobile, d).toDouble(),
]),
],
),
),
],
);
}
}
/// The documents, by tab: the outline as a table, the rest waiting for content.
class _Documents extends StatefulWidget {
const _Documents();
@override
State<_Documents> createState() => _DocumentsState();
}
class _DocumentsState extends State<_Documents> {
var _picked = <int>{};
static const _rows = [
('Cover page', 'Cover page', 'In Process', 18, 5, 'Eddie Lake'),
('Table of contents', 'Table of contents', 'Done', 29, 24, 'Eddie Lake'),
('Executive summary', 'Narrative', 'Done', 10, 13, 'Eddie Lake'),
('Technical approach', 'Narrative', 'Done', 27, 23, 'Jamik Tashpulatov'),
('Design', 'Narrative', 'In Process', 2, 16, 'Jamik Tashpulatov'),
('Capabilities', 'Narrative', 'In Process', 20, 8, 'Jamik Tashpulatov'),
(
'Integration with existing systems',
'Narrative',
'In Process',
19,
21,
'Jamik Tashpulatov',
),
(
'Innovation and advantages',
'Narrative',
'Done',
25,
26,
'Assign reviewer',
),
(
'Overview of EMR system',
'Technical content',
'Done',
7,
23,
'Assign reviewer',
),
('Risk management plan', 'Narrative', 'Done', 22, 15, 'Assign reviewer'),
];
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
Widget waiting(String what) => Container(
height: 240,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg),
border: Border.all(color: c.border),
),
child: Text(
'No $what yet.',
style: t.text.sm.copyWith(color: c.mutedForeground),
),
);
return Tabs(
variant: TabsVariant.line,
tabs: [
TabItem(
label: const Text('Outline'),
content: Padding(
padding: const EdgeInsets.only(top: 16),
child: DataTable(
selected: _picked,
onSelectChanged: (s) => setState(() => _picked = s),
columns: const [
DataColumn(Text('Header'), flex: 3),
DataColumn(Text('Section Type'), flex: 2),
DataColumn(Text('Status'), flex: 2),
DataColumn(Text('Target'), numeric: true),
DataColumn(Text('Limit'), numeric: true),
DataColumn(Text('Reviewer'), flex: 2),
],
rows: [
for (final (header, type, status, target, limit, reviewer)
in _rows)
[
Text(
header,
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: Badge(
variant: BadgeVariant.outline,
child: Text(type, overflow: TextOverflow.ellipsis),
),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: Badge(
variant: BadgeVariant.outline,
leading: AshlarIcon(
status == 'Done'
? AshlarIconsRoundedStroke.check
: AshlarIconsRoundedStroke.clock,
),
child: Text(status),
),
),
Text('$target'),
Text('$limit'),
Text(
reviewer,
overflow: TextOverflow.ellipsis,
style: reviewer == 'Assign reviewer'
? t.text.sm.copyWith(color: c.mutedForeground)
: null,
),
],
],
),
),
),
TabItem(
label: const Text('Past Performance'),
content: Padding(
padding: const EdgeInsets.only(top: 16),
child: waiting('past performance'),
),
),
TabItem(
label: const Text('Key Personnel'),
content: Padding(
padding: const EdgeInsets.only(top: 16),
child: waiting('key personnel'),
),
),
TabItem(
label: const Text('Focus Documents'),
content: Padding(
padding: const EdgeInsets.only(top: 16),
child: waiting('focus documents'),
),
),
],
);
}
}
Sidebars
Sixteen ways to lay out an app around its navigation.
sidebar-01
Documentation navigation grouped by section, with versions and search.
ashlar add sidebar-01
lib/blocks/sidebar_01.dart 252 lines
// Ashlar UI block: sidebar-01. Copied into your app by `ashlar add sidebar-01`; it is yours
// to change. Documentation navigation grouped by section, with a version switcher and a
// search field at the top.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/input.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages, one of them open.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
[
'Routing',
'Data Fetching',
'Rendering',
'State',
'Styling',
'Testing',
'Accessibility',
'Deploying',
],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
(
'Architecture',
['Layers', 'Hot Reload', 'Platform Channels', 'Supported Platforms'],
),
('Community', ['Contribution Guide']),
];
/// A sidebar of documentation, grouped by section.
class Sidebar01 extends StatefulWidget {
const Sidebar01({super.key});
@override
State<Sidebar01> createState() => _Sidebar01State();
}
class _Sidebar01State extends State<Sidebar01> {
var _page = 'Data Fetching';
var _version = '1.0.1';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_VersionSwitcher(
version: _version,
versions: const ['1.0.1', '1.1.0-alpha', '2.0.0-beta1'],
onChanged: (v) => setState(() => _version = v),
),
const SizedBox(height: 8),
const Input(
placeholder: 'Search the docs…',
semanticLabel: 'Search the docs',
leading: AshlarIcon(AshlarIconsRoundedStroke.search),
),
],
),
groups: [
for (final (section, pages) in _sections)
SidebarGroup(label: section, [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
]),
],
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
/// The product's mark and name, and a menu of its versions.
class _VersionSwitcher extends StatelessWidget {
const _VersionSwitcher({
required this.version,
required this.versions,
required this.onChanged,
});
final String version;
final List<String> versions;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Documentation, version $version',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v$version', style: t.text.xs),
],
),
),
],
),
),
items: [
for (final v in versions)
MenuItem(
selected: v == version,
onPressed: () => onChanged(v),
child: Text(v),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-02
Documentation in sections that fold away, with versions and search.
ashlar add sidebar-02
lib/blocks/sidebar_02.dart 259 lines
// Ashlar UI block: sidebar-02. Copied into your app by `ashlar add sidebar-02`; it is yours
// to change. Documentation navigation in sections that fold away, with a version switcher
// and a search field at the top.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/input.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages, one of them open.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
[
'Routing',
'Data Fetching',
'Rendering',
'State',
'Styling',
'Testing',
'Accessibility',
'Deploying',
],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
(
'Architecture',
['Layers', 'Hot Reload', 'Platform Channels', 'Supported Platforms'],
),
('Community', ['Contribution Guide']),
];
/// A sidebar of documentation in sections that fold.
class Sidebar02 extends StatefulWidget {
const Sidebar02({super.key});
@override
State<Sidebar02> createState() => _Sidebar02State();
}
class _Sidebar02State extends State<Sidebar02> {
var _page = 'Data Fetching';
var _version = '1.0.1';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_VersionSwitcher(
version: _version,
versions: const ['1.0.1', '1.1.0-alpha', '2.0.0-beta1'],
onChanged: (v) => setState(() => _version = v),
),
const SizedBox(height: 8),
const Input(
placeholder: 'Search the docs…',
semanticLabel: 'Search the docs',
leading: AshlarIcon(AshlarIconsRoundedStroke.search),
),
],
),
groups: [
SidebarGroup([
for (final (section, pages) in _sections)
SidebarItem(
section,
icon: null,
initiallyOpen: true,
children: [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
],
),
]),
],
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
/// The product's mark and name, and a menu of its versions.
class _VersionSwitcher extends StatelessWidget {
const _VersionSwitcher({
required this.version,
required this.versions,
required this.onChanged,
});
final String version;
final List<String> versions;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Documentation, version $version',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v$version', style: t.text.xs),
],
),
),
],
),
),
items: [
for (final v in versions)
MenuItem(
selected: v == version,
onPressed: () => onChanged(v),
child: Text(v),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-03
Documentation with each section's pages listed under it.
ashlar add sidebar-03
lib/blocks/sidebar_03.dart 217 lines
// Ashlar UI block: sidebar-03. Copied into your app by `ashlar add sidebar-03`; it is yours
// to change. Documentation navigation with each section's pages listed under it.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages, one of them open.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
[
'Routing',
'Data Fetching',
'Rendering',
'State',
'Styling',
'Testing',
'Accessibility',
'Deploying',
],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
(
'Architecture',
['Layers', 'Hot Reload', 'Platform Channels', 'Supported Platforms'],
),
('Community', ['Contribution Guide']),
];
/// A sidebar of documentation, each section's pages under it.
class Sidebar03 extends StatefulWidget {
const Sidebar03({super.key});
@override
State<Sidebar03> createState() => _Sidebar03State();
}
class _Sidebar03State extends State<Sidebar03> {
var _page = 'Data Fetching';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: const _Brand(),
groups: [
SidebarGroup([
for (final (section, pages) in _sections)
SidebarItem(
section,
icon: null,
initiallyOpen: true,
children: [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
],
),
]),
],
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
/// The product's mark, name and version.
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v1.0.0', style: t.text.xs),
],
),
),
],
),
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-04
A floating sidebar, sections with their pages under them.
ashlar add sidebar-04
lib/blocks/sidebar_04.dart 219 lines
// Ashlar UI block: sidebar-04. Copied into your app by `ashlar add sidebar-04`; it is yours
// to change. A floating sidebar: a rounded panel in from the edge, sections with their pages
// under them.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages, one of them open.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
[
'Routing',
'Data Fetching',
'Rendering',
'State',
'Styling',
'Testing',
'Accessibility',
'Deploying',
],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
(
'Architecture',
['Layers', 'Hot Reload', 'Platform Channels', 'Supported Platforms'],
),
('Community', ['Contribution Guide']),
];
/// A floating sidebar of documentation.
class Sidebar04 extends StatefulWidget {
const Sidebar04({super.key});
@override
State<Sidebar04> createState() => _Sidebar04State();
}
class _Sidebar04State extends State<Sidebar04> {
var _page = 'Data Fetching';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
variant: SidebarVariant.floating,
header: const _Brand(),
groups: [
SidebarGroup([
for (final (section, pages) in _sections)
SidebarItem(
section,
icon: null,
initiallyOpen: true,
children: [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
],
),
]),
],
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
/// The product's mark, name and version.
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v1.0.0', style: t.text.xs),
],
),
),
],
),
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-05
Sections that open as you need them, with search.
ashlar add sidebar-05
lib/blocks/sidebar_05.dart 230 lines
// Ashlar UI block: sidebar-05. Copied into your app by `ashlar add sidebar-05`; it is yours
// to change. Documentation navigation whose sections open as you need them, with a search
// field at the top.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/input.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages, one of them open.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
[
'Routing',
'Data Fetching',
'Rendering',
'State',
'Styling',
'Testing',
'Accessibility',
'Deploying',
],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
(
'Architecture',
['Layers', 'Hot Reload', 'Platform Channels', 'Supported Platforms'],
),
('Community', ['Contribution Guide']),
];
/// A sidebar of documentation with sections that open as needed.
class Sidebar05 extends StatefulWidget {
const Sidebar05({super.key});
@override
State<Sidebar05> createState() => _Sidebar05State();
}
class _Sidebar05State extends State<Sidebar05> {
var _page = 'Data Fetching';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Brand(),
const SizedBox(height: 8),
const Input(
placeholder: 'Search the docs…',
semanticLabel: 'Search the docs',
leading: AshlarIcon(AshlarIconsRoundedStroke.search),
),
],
),
groups: [
SidebarGroup([
for (final (section, pages) in _sections)
SidebarItem(
section,
icon: null,
initiallyOpen: section == _sections.first.$1,
children: [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
],
),
]),
],
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
/// The product's mark, name and version.
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v1.0.0', style: t.text.xs),
],
),
),
],
),
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-06
Sections that open their pages as menus, a newsletter card at the foot.
ashlar add sidebar-06
lib/blocks/sidebar_06.dart 274 lines
// Ashlar UI block: sidebar-06. Copied into your app by `ashlar add sidebar-06`; it is yours
// to change. Each section opens its pages as a menu beside the sidebar, and a newsletter
// sign-up sits at the bottom.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/dropdown_menu.dart';
import '../ui/input.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample navigation: sections of pages.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
['Routing', 'Data Fetching', 'Rendering', 'State', 'Styling', 'Testing'],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
('Architecture', ['Layers', 'Hot Reload', 'Platform Channels']),
('Community', ['Contribution Guide']),
];
/// A sidebar whose sections open as menus.
class Sidebar06 extends StatefulWidget {
const Sidebar06({super.key});
@override
State<Sidebar06> createState() => _Sidebar06State();
}
class _Sidebar06State extends State<Sidebar06> {
var _page = 'Data Fetching';
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: const _Brand(),
groups: [
SidebarGroup.custom(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (section, pages) in _sections)
DropdownMenu(
side: PopoverSide.right,
width: 192,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
onPressed: menu.open,
trailing: const AshlarIcon(
AshlarIconsRoundedStroke.ellipsis,
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
section,
style: t.text.sm.copyWith(
fontWeight: pages.contains(_page)
? FontWeight.w600
: null,
),
),
),
),
items: [
for (final page in pages)
MenuItem(
selected: page == _page,
onPressed: () => setState(() => _page = page),
child: Text(page),
),
],
),
],
),
),
],
footer: const _OptIn(),
child: _Page(
trail: [_sections.firstWhere((s) => s.$2.contains(_page)).$1, _page],
),
);
}
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Documentation',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('v1.0.0', style: t.text.xs),
],
),
),
],
),
);
}
}
/// A small card asking for an email address for the newsletter.
class _OptIn extends StatelessWidget {
const _OptIn();
@override
Widget build(BuildContext context) => Card(
size: CardSize.sm,
children: [
const CardHeader(
title: Text('Subscribe to our newsletter'),
description: Text(
'Opt-in to receive updates and news about the sidebar.',
),
),
CardContent(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Input(
placeholder: 'Email',
semanticLabel: 'Email',
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 8),
Button(
size: ButtonSize.sm,
variant: ButtonVariant.secondary,
expand: true,
onPressed: () {},
child: const Text('Subscribe'),
),
],
),
),
],
);
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-07
An app sidebar that collapses to icons: teams, sections, projects, user.
ashlar add sidebar-07
lib/blocks/sidebar_07.dart 339 lines
// Ashlar UI block: sidebar-07. Copied into your app by `ashlar add sidebar-07`; it is yours
// to change. An app's sidebar that collapses to a strip of icons: teams, the main
// sections with their pages, projects, and who is signed in.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample sections, each with its pages.
const _platform = [
(
'Playground',
AshlarIconsRoundedStroke.terminal,
['History', 'Starred', 'Settings'],
),
('Models', AshlarIconsRoundedStroke.bot, ['Genesis', 'Explorer', 'Quantum']),
(
'Documentation',
AshlarIconsRoundedStroke.bookOpen,
['Introduction', 'Get Started', 'Tutorials', 'Changelog'],
),
(
'Settings',
AshlarIconsRoundedStroke.settings,
['General', 'Team', 'Billing', 'Limits'],
),
];
/// Sample projects.
const _projects = [
('Design Engineering', AshlarIconsRoundedStroke.frame),
('Sales & Marketing', AshlarIconsRoundedStroke.chartPie),
('Travel', AshlarIconsRoundedStroke.map),
];
/// A sidebar that collapses to icons, each with its label in a tooltip.
class Sidebar07 extends StatelessWidget {
const Sidebar07({super.key});
@override
Widget build(BuildContext context) => SidebarLayout(
header: const _TeamSwitcher(),
groups: [
SidebarGroup(label: 'Platform', [
for (final (i, (label, icon, pages)) in _platform.indexed)
SidebarItem(
label,
icon: icon,
initiallyOpen: i == 0,
children: [
for (final page in pages)
SidebarItem(page, icon: null, onPressed: () {}),
],
),
]),
SidebarGroup(label: 'Projects', [
for (final (label, icon) in _projects)
SidebarItem(
label,
icon: icon,
onPressed: () {},
action: SidebarAction(
icon: AshlarIconsRoundedStroke.ellipsis,
label: 'More for $label',
onPressed: () {},
),
),
SidebarItem(
'More',
icon: AshlarIconsRoundedStroke.ellipsis,
onPressed: () {},
),
]),
],
footer: const _NavUser(),
child: const _Page(trail: ['Build Your App', 'Data Fetching']),
);
}
/// The team you are working in, and a menu to change it.
class _TeamSwitcher extends StatefulWidget {
const _TeamSwitcher();
@override
State<_TeamSwitcher> createState() => _TeamSwitcherState();
}
class _TeamSwitcherState extends State<_TeamSwitcher> {
// Sample teams.
static const _teams = [
('Acme Inc', 'Enterprise', AshlarIconsRoundedStroke.galleryVerticalEnd),
('Acme Corp.', 'Startup', AshlarIconsRoundedStroke.audioLines),
('Evil Corp.', 'Free', AshlarIconsRoundedStroke.command),
];
var _team = 0;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final (name, plan, icon) = _teams[_team];
return DropdownMenu(
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Team: $name',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(icon, color: c.sidebarPrimaryForeground),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(plan, style: t.text.xs),
],
),
),
],
),
),
items: [
const MenuLabel(child: Text('Teams')),
for (final (i, (name, _, icon)) in _teams.indexed)
MenuItem(
leading: AshlarIcon(icon),
shortcut: Text('⌘${i + 1}'),
onPressed: () => setState(() => _team = i),
child: Text(name),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
onPressed: () {},
child: const Text('Add team'),
),
],
);
}
}
/// Who is signed in, and a menu for their account.
class _NavUser extends StatelessWidget {
const _NavUser();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.sparkles),
onPressed: () {},
child: const Text('Upgrade to Pro'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.badgeCheck),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-08
The page inset as a card, help and feedback at the sidebar's foot.
ashlar add sidebar-08
lib/blocks/sidebar_08.dart 311 lines
// Ashlar UI block: sidebar-08. Copied into your app by `ashlar add sidebar-08`; it is yours
// to change. The page set into the sidebar as a card, with help and feedback kept at the
// sidebar's foot.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample sections, each with its pages.
const _platform = [
(
'Playground',
AshlarIconsRoundedStroke.terminal,
['History', 'Starred', 'Settings'],
),
('Models', AshlarIconsRoundedStroke.bot, ['Genesis', 'Explorer', 'Quantum']),
(
'Documentation',
AshlarIconsRoundedStroke.bookOpen,
['Introduction', 'Get Started', 'Tutorials', 'Changelog'],
),
(
'Settings',
AshlarIconsRoundedStroke.settings,
['General', 'Team', 'Billing', 'Limits'],
),
];
/// Sample projects.
const _projects = [
('Design Engineering', AshlarIconsRoundedStroke.frame),
('Sales & Marketing', AshlarIconsRoundedStroke.chartPie),
('Travel', AshlarIconsRoundedStroke.map),
];
/// An inset sidebar, with secondary navigation at its foot.
class Sidebar08 extends StatelessWidget {
const Sidebar08({super.key});
@override
Widget build(BuildContext context) => SidebarLayout(
variant: SidebarVariant.inset,
header: const _Brand(),
groups: [
SidebarGroup(label: 'Platform', [
for (final (i, (label, icon, pages)) in _platform.indexed)
SidebarItem(
label,
icon: icon,
initiallyOpen: i == 0,
children: [
for (final page in pages)
SidebarItem(page, icon: null, onPressed: () {}),
],
),
]),
SidebarGroup(label: 'Projects', [
for (final (label, icon) in _projects)
SidebarItem(
label,
icon: icon,
onPressed: () {},
action: SidebarAction(
icon: AshlarIconsRoundedStroke.ellipsis,
label: 'More for $label',
onPressed: () {},
),
),
]),
SidebarGroup(atEnd: true, [
SidebarItem(
'Support',
icon: AshlarIconsRoundedStroke.lifeBuoy,
onPressed: () {},
),
SidebarItem(
'Feedback',
icon: AshlarIconsRoundedStroke.send,
onPressed: () {},
),
]),
],
footer: const _NavUser(),
child: const _Page(trail: ['Build Your App', 'Data Fetching']),
);
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.command,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Acme Inc',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text('Enterprise', style: t.text.xs),
],
),
),
],
),
);
}
}
/// Who is signed in, and a menu for their account.
class _NavUser extends StatelessWidget {
const _NavUser();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.sparkles),
onPressed: () {},
child: const Text('Upgrade to Pro'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.badgeCheck),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-09
Two sidebars as a mail app has them: folders, and their messages.
ashlar add sidebar-09
lib/blocks/sidebar_09.dart 343 lines
// Ashlar UI block: sidebar-09. Copied into your app by `ashlar add sidebar-09`; it is yours
// to change. Two sidebars side by side, as a mail app has them: a strip of folders that
// stays, and the folder's messages beside it, which fold away.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/input.dart';
import '../ui/label.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/switch.dart';
import '../ui/theme.dart';
import '../ui/tooltip.dart';
/// Sample folders.
const _folders = [
('Inbox', AshlarIconsRoundedStroke.inbox),
('Drafts', AshlarIconsRoundedStroke.file),
('Sent', AshlarIconsRoundedStroke.send),
('Junk', AshlarIconsRoundedStroke.archive),
('Trash', AshlarIconsRoundedStroke.trash),
];
/// Sample messages: who from, when, the subject, the start of the text, and whether read.
const _mail = [
(
'Achieng Otieno',
'09:34 AM',
'Meeting tomorrow',
'Can we move the review to ten? The figures from the field team came in late.',
false,
),
(
'Kofi Mensah',
'Yesterday',
'Re: Project update',
'Thanks for the update. The new screens look clean and the team is happy with them.',
false,
),
(
'Amina Diallo',
'2 days ago',
'Weekend plans',
'Hiking on Saturday if the weather holds. Bring water and a jacket.',
true,
),
(
'Tendai Moyo',
'2 days ago',
'Question about the budget',
'I went through the numbers again and think we can save on hosting.',
true,
),
(
'Lerato Dlamini',
'1 week ago',
'Re: Conference talk',
'The talk is accepted. Slides are due two weeks before the day.',
true,
),
(
'Juma Mwangi',
'1 week ago',
'Invoice 2041',
'Please find the invoice for September attached. Payment within 30 days.',
true,
),
(
'Nia Okafor',
'2 weeks ago',
'Design review notes',
'Notes from the session are in the shared folder. Most points are small.',
true,
),
];
/// A strip of folders beside a list of the open folder's messages.
class Sidebar09 extends StatefulWidget {
const Sidebar09({super.key});
@override
State<Sidebar09> createState() => _Sidebar09State();
}
class _Sidebar09State extends State<Sidebar09> {
var _folder = 'Inbox';
var _unread = false;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final mail = [
for (final m in _mail)
if (!_unread || !m.$5) m,
];
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The folders: always there, on a phone too.
DecoratedBox(
decoration: BoxDecoration(
color: c.sidebar,
border: BorderDirectional(end: BorderSide(color: c.sidebarBorder)),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.command,
color: c.sidebarPrimaryForeground,
),
),
const SizedBox(height: 16),
for (final (name, icon) in _folders)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Tooltip(
message: name,
side: TooltipSide.right,
child: Button(
variant: name == _folder
? ButtonVariant.secondary
: ButtonVariant.ghost,
size: ButtonSize.icon,
semanticLabel: name,
onPressed: () => setState(() => _folder = name),
child: AshlarIcon(icon),
),
),
),
const Spacer(),
const Avatar(fallback: 'WK', semanticLabel: 'Wanjiru Kamau'),
],
),
),
),
),
Expanded(
child: SidebarLayout(
collapse: SidebarCollapse.offcanvas,
width: 320,
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
_folder,
style: t.text.base.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const Label(child: Text('Unreads')),
const SizedBox(width: 8),
Switch(
size: SwitchSize.sm,
value: _unread,
semanticLabel: 'Unread only',
onChanged: (v) => setState(() => _unread = v),
),
],
),
const SizedBox(height: 12),
const Input(
placeholder: 'Type to search…',
semanticLabel: 'Search mail',
leading: AshlarIcon(AshlarIconsRoundedStroke.search),
),
],
),
groups: [
SidebarGroup.custom(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, (from, sent, subject, text, _))
in mail.indexed) ...[
if (i > 0) const Separator(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
from,
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(sent, style: t.text.xs),
],
),
const SizedBox(height: 4),
Text(
subject,
style: t.text.sm.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(
color: c.mutedForeground,
),
),
],
),
),
],
],
),
),
],
child: _Page(trail: ['All Inboxes', _folder]),
),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-10
A notes app's sidebar, with the page's actions in a popover.
ashlar add sidebar-10
lib/blocks/sidebar_10.dart 369 lines
// Ashlar UI block: sidebar-10. Copied into your app by `ashlar add sidebar-10`; it is yours
// to change. A notes app: search and the inbox at the top, favourites and workspaces under
// them, and the page's actions in a panel that opens from its header.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample pages and workspaces.
const _favorites = [
'Project Management & Task Tracking',
'Family Recipe Collection & Meal Planning',
'Fitness Tracker & Workout Routines',
'Book Notes & Reading List',
];
const _workspaces = [
(
'Personal Life Management',
['Daily Journal & Reflection', 'Health & Wellness Tracker'],
),
(
'Professional Development',
['Career Objectives & Milestones', 'Skill Acquisition Plan'],
),
(
'Creative Projects',
['Writing Ideas & Story Outlines', 'Art & Design Portfolio'],
),
];
/// A sidebar of pages, and the page's actions in a popover.
class Sidebar10 extends StatelessWidget {
const Sidebar10({super.key});
@override
Widget build(BuildContext context) => SidebarLayout(
header: const _TeamSwitcher(),
groups: [
SidebarGroup([
SidebarItem(
'Search',
icon: AshlarIconsRoundedStroke.search,
onPressed: () {},
),
SidebarItem(
'Ask AI',
icon: AshlarIconsRoundedStroke.sparkles,
onPressed: () {},
),
SidebarItem(
'Home',
icon: AshlarIconsRoundedStroke.house,
active: true,
onPressed: () {},
),
SidebarItem(
'Inbox',
icon: AshlarIconsRoundedStroke.inbox,
badge: '10',
onPressed: () {},
),
]),
SidebarGroup(label: 'Favorites', [
for (final page in _favorites)
SidebarItem(
page,
icon: AshlarIconsRoundedStroke.fileText,
onPressed: () {},
action: SidebarAction(
icon: AshlarIconsRoundedStroke.ellipsis,
label: 'More for $page',
onPressed: () {},
),
),
]),
SidebarGroup(label: 'Workspaces', [
for (final (name, pages) in _workspaces)
SidebarItem(
name,
icon: AshlarIconsRoundedStroke.folder,
children: [
for (final page in pages)
SidebarItem(page, icon: null, onPressed: () {}),
],
),
]),
SidebarGroup(atEnd: true, [
SidebarItem(
'Calendar',
icon: AshlarIconsRoundedStroke.calendar,
onPressed: () {},
),
SidebarItem(
'Settings',
icon: AshlarIconsRoundedStroke.settings,
onPressed: () {},
),
SidebarItem(
'Templates',
icon: AshlarIconsRoundedStroke.blocks,
onPressed: () {},
),
SidebarItem(
'Trash',
icon: AshlarIconsRoundedStroke.trash,
onPressed: () {},
),
SidebarItem(
'Help',
icon: AshlarIconsRoundedStroke.circleQuestionMark,
onPressed: () {},
),
]),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 56,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(
height: 16,
child: Separator(axis: Axis.vertical),
),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(items: [BreadcrumbItem(_favorites.first)]),
),
const _Actions(),
],
),
),
),
const Expanded(child: _Placeholder()),
],
),
);
}
/// When the page was edited, a star, and the page's actions in a popover.
class _Actions extends StatelessWidget {
const _Actions();
static const _groups = [
[
('Customize Page', AshlarIconsRoundedStroke.settings),
('Turn into wiki', AshlarIconsRoundedStroke.fileText),
],
[
('Copy Link', AshlarIconsRoundedStroke.link),
('Duplicate', AshlarIconsRoundedStroke.layers),
('Move to Trash', AshlarIconsRoundedStroke.trash),
],
[
('View analytics', AshlarIconsRoundedStroke.chartLine),
('Version History', AshlarIconsRoundedStroke.clock),
('Notifications', AshlarIconsRoundedStroke.bell),
],
];
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final wide = MediaQuery.sizeOf(context).width >= 768;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (wide)
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: Text(
'Edit Oct 08',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
Button(
variant: ButtonVariant.ghost,
size: ButtonSize.icon,
semanticLabel: 'Add to favorites',
onPressed: () {},
child: const AshlarIcon(AshlarIconsRoundedStroke.star),
),
Popover(
align: PopoverAlign.end,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
size: ButtonSize.icon,
semanticLabel: 'Page actions',
onPressed: menu.open,
child: const AshlarIcon(AshlarIconsRoundedStroke.ellipsis),
),
content: (context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, group) in _groups.indexed) ...[
if (i > 0)
const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Separator(),
),
for (final (label, icon) in group)
Button(
variant: ButtonVariant.ghost,
size: ButtonSize.sm,
expand: true,
leading: AshlarIcon(icon),
onPressed: () {},
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(label),
),
),
],
],
),
),
],
);
}
}
/// The team you are working in, and a menu to change it.
class _TeamSwitcher extends StatefulWidget {
const _TeamSwitcher();
@override
State<_TeamSwitcher> createState() => _TeamSwitcherState();
}
class _TeamSwitcherState extends State<_TeamSwitcher> {
// Sample teams.
static const _teams = [
('Acme Inc', 'Enterprise', AshlarIconsRoundedStroke.galleryVerticalEnd),
('Acme Corp.', 'Startup', AshlarIconsRoundedStroke.audioLines),
('Evil Corp.', 'Free', AshlarIconsRoundedStroke.command),
];
var _team = 0;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final (name, plan, icon) = _teams[_team];
return DropdownMenu(
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Team: $name',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(icon, color: c.sidebarPrimaryForeground),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(plan, style: t.text.xs),
],
),
),
],
),
),
items: [
const MenuLabel(child: Text('Teams')),
for (final (i, (name, _, icon)) in _teams.indexed)
MenuItem(
leading: AshlarIcon(icon),
shortcut: Text('⌘${i + 1}'),
onPressed: () => setState(() => _team = i),
child: Text(name),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
onPressed: () {},
child: const Text('Add team'),
),
],
);
}
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-11
A project's files as a tree, the changed ones above it.
ashlar add sidebar-11
lib/blocks/sidebar_11.dart 291 lines
// Ashlar UI block: sidebar-11. Copied into your app by `ashlar add sidebar-11`; it is yours
// to change. A project's files as a tree whose folders open and close, with the changed
// files listed above it.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/pressable.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// A file, or a folder of more.
class _Node {
const _Node(this.name, [this.children]);
final String name;
final List<_Node>? children;
}
/// A sample project.
const _tree = [
_Node('lib', [
_Node('main.dart'),
_Node('ui', [_Node('button.dart'), _Node('card.dart')]),
_Node('screens', [_Node('home.dart'), _Node('settings.dart')]),
]),
_Node('test', [_Node('widget_test.dart')]),
_Node('web', [_Node('index.html'), _Node('favicon.png')]),
_Node('analysis_options.yaml'),
_Node('pubspec.yaml'),
_Node('README.md'),
];
/// Files changed since the last commit: modified or untracked.
const _changes = [
('lib/ui/button.dart', 'M'),
('lib/screens/settings.dart', 'U'),
('pubspec.yaml', 'M'),
];
/// A sidebar with a file tree.
class Sidebar11 extends StatefulWidget {
const Sidebar11({super.key});
@override
State<Sidebar11> createState() => _Sidebar11State();
}
class _Sidebar11State extends State<Sidebar11> {
var _file = 'lib/ui/button.dart';
@override
Widget build(BuildContext context) => SidebarLayout(
collapse: SidebarCollapse.offcanvas,
groups: [
SidebarGroup(label: 'Changes', [
for (final (path, state) in _changes)
SidebarItem(
path.split('/').last,
icon: AshlarIconsRoundedStroke.file,
badge: state,
active: path == _file,
onPressed: () => setState(() => _file = path),
),
]),
SidebarGroup.custom(
label: 'Files',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final node in _tree)
_Branch(
node: node,
path: node.name,
picked: _file,
onPicked: (p) => setState(() => _file = p),
),
],
),
),
],
child: _Page(trail: _file.split('/')),
);
}
/// One row of the tree: a file to pick, or a folder that opens onto its own rows.
class _Branch extends StatefulWidget {
const _Branch({
required this.node,
required this.path,
required this.picked,
required this.onPicked,
});
final _Node node;
final String path, picked;
final ValueChanged<String> onPicked;
@override
State<_Branch> createState() => _BranchState();
}
class _BranchState extends State<_Branch> {
// Folders on the way to the picked file start open.
late var _open = widget.picked.startsWith('${widget.path}/');
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final children = widget.node.children;
final folder = children != null;
final picked = widget.path == widget.picked;
final row = Pressable(
onPressed: folder
? () => setState(() => _open = !_open)
: () => widget.onPicked(widget.path),
expanded: folder ? _open : null,
selected: folder ? null : picked,
semanticLabel: widget.node.name,
builder: (context, states) => Container(
height: 32,
padding: const EdgeInsetsDirectional.only(start: 8, end: 8),
decoration: BoxDecoration(
color: picked || states.hovered
? c.sidebarAccent
: const Color(0x00000000),
borderRadius: BorderRadius.circular(t.radiusMd),
border: states.focused ? Border.all(color: c.sidebarRing) : null,
),
child: Row(
children: [
if (folder) ...[
AnimatedRotation(
turns: _open ? .25 : 0,
duration: t.duration,
child: AshlarIcon(
isRtl(context)
? AshlarIconsRoundedStroke.chevronLeft
: AshlarIconsRoundedStroke.chevronRight,
),
),
const SizedBox(width: 6),
],
AshlarIcon(
folder
? (_open
? AshlarIconsRoundedStroke.folderOpen
: AshlarIconsRoundedStroke.folder)
: AshlarIconsRoundedStroke.file,
),
const SizedBox(width: 8),
Expanded(
child: Text(widget.node.name, overflow: TextOverflow.ellipsis),
),
],
),
),
);
if (!folder) return row;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
row,
if (_open)
Container(
margin: const EdgeInsetsDirectional.only(start: 15),
padding: const EdgeInsetsDirectional.only(start: 6),
decoration: BoxDecoration(
border: BorderDirectional(
start: BorderSide(color: c.sidebarBorder),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in children)
_Branch(
node: child,
path: '${widget.path}/${child.name}',
picked: widget.picked,
onPicked: widget.onPicked,
),
],
),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-12
A calendar app: a month to pick from, and calendars to show.
ashlar add sidebar-12
lib/blocks/sidebar_12.dart 305 lines
// Ashlar UI block: sidebar-12. Copied into your app by `ashlar add sidebar-12`; it is yours
// to change. A calendar app: who is signed in, a month to pick a day from, and the
// calendars to show.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/calendar.dart';
import '../ui/checkbox.dart';
import '../ui/dropdown_menu.dart';
import '../ui/field.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// A sidebar with a calendar.
class Sidebar12 extends StatefulWidget {
const Sidebar12({super.key});
@override
State<Sidebar12> createState() => _Sidebar12State();
}
class _Sidebar12State extends State<Sidebar12> {
var _day = DateTime(2026, 10, 13);
@override
Widget build(BuildContext context) {
final names = DateNames.of(context);
return SidebarLayout(
collapse: SidebarCollapse.offcanvas,
header: const _NavUser(),
groups: [
SidebarGroup.custom(
Calendar(selected: _day, onSelected: (d) => setState(() => _day = d)),
),
const SidebarGroup.custom(Separator()),
const SidebarGroup.custom(_Calendars()),
],
footer: Button(
variant: ButtonVariant.ghost,
expand: true,
leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
onPressed: () {},
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: Text('New Calendar'),
),
),
child: _Page(trail: [names.monthYear(_day), names.date(_day)]),
);
}
}
/// Sample calendars, in groups.
const _calendars = [
('My Calendars', ['Personal', 'Work', 'Family']),
('Favorites', ['Holidays', 'Birthdays']),
('Other', ['Travel', 'Reminders', 'Deadlines']),
];
/// The calendars to show, by group, each group folding away.
class _Calendars extends StatefulWidget {
const _Calendars();
@override
State<_Calendars> createState() => _CalendarsState();
}
class _CalendarsState extends State<_Calendars> {
final _shown = {'Personal', 'Work', 'Holidays'};
final _open = {'My Calendars'};
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (group, names) in _calendars) ...[
Button(
variant: ButtonVariant.ghost,
size: ButtonSize.sm,
expand: true,
onPressed: () => setState(
() => _open.contains(group)
? _open.remove(group)
: _open.add(group),
),
trailing: AnimatedRotation(
turns: _open.contains(group) ? .25 : 0,
duration: t.duration,
child: AshlarIcon(
isRtl(context)
? AshlarIconsRoundedStroke.chevronLeft
: AshlarIconsRoundedStroke.chevronRight,
),
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(group),
),
),
if (_open.contains(group))
for (final name in names)
Padding(
padding: const EdgeInsetsDirectional.only(
start: 12,
top: 4,
bottom: 4,
),
child: Field(
axis: Axis.horizontal,
label: Text(name),
child: Checkbox(
value: _shown.contains(name),
onChanged: (v) => setState(
() => v ? _shown.add(name) : _shown.remove(name),
),
),
),
),
],
],
);
}
}
/// Who is signed in, and a menu for their account.
class _NavUser extends StatelessWidget {
const _NavUser();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.sparkles),
onPressed: () {},
child: const Text('Upgrade to Pro'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.badgeCheck),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-13
Settings in a dialog, their sections down its side.
ashlar add sidebar-13
lib/blocks/sidebar_13.dart 119 lines
// Ashlar UI block: sidebar-13. Copied into your app by `ashlar add sidebar-13`; it is yours
// to change. Settings in a dialog: their sections down the side, the open one beside them.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dialog.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// Sample settings sections.
const _sections = [
('Notifications', AshlarIconsRoundedStroke.bell),
('Navigation', AshlarIconsRoundedStroke.menu),
('Home', AshlarIconsRoundedStroke.house),
('Appearance', AshlarIconsRoundedStroke.paintbrush),
('Messages & media', AshlarIconsRoundedStroke.messageCircle),
('Language & region', AshlarIconsRoundedStroke.globe),
('Accessibility', AshlarIconsRoundedStroke.keyboard),
('Mark as read', AshlarIconsRoundedStroke.check),
('Audio & video', AshlarIconsRoundedStroke.video),
('Connected accounts', AshlarIconsRoundedStroke.link),
('Privacy & visibility', AshlarIconsRoundedStroke.lock),
('Advanced', AshlarIconsRoundedStroke.settings),
];
/// A page with a button that opens the settings dialog.
class Sidebar13 extends StatelessWidget {
const Sidebar13({super.key});
@override
Widget build(BuildContext context) => Center(
child: Button(
onPressed: () => showAshlarDialog<void>(
context: context,
builder: (context) => const Dialog(maxWidth: 800, content: _Settings()),
),
child: const Text('Open Dialog'),
),
);
}
/// The settings: sections down the side where there is room, the open one beside them.
class _Settings extends StatefulWidget {
const _Settings();
@override
State<_Settings> createState() => _SettingsState();
}
class _SettingsState extends State<_Settings> {
var _section = 'Messages & media';
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final wide = MediaQuery.sizeOf(context).width >= 768;
final box = BoxDecoration(
color: c.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
final content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Breadcrumb(
items: [
BreadcrumbItem('Settings', onPressed: () {}),
BreadcrumbItem(_section),
],
),
const SizedBox(height: 12),
const Separator(),
const SizedBox(height: 16),
for (var i = 0; i < 6; i++) ...[
if (i > 0) const SizedBox(height: 16),
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
],
],
);
if (!wide) return content;
return SizedBox(
height: 480,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 200,
child: ListView(
children: [
for (final (name, icon) in _sections)
Button(
variant: name == _section
? ButtonVariant.secondary
: ButtonVariant.ghost,
size: ButtonSize.sm,
expand: true,
leading: AshlarIcon(icon),
onPressed: () => setState(() => _section = name),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(name, overflow: TextOverflow.ellipsis),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(child: SingleChildScrollView(child: content)),
],
),
);
}
}
sidebar-14
A table of contents on the right of the page.
ashlar add sidebar-14
lib/blocks/sidebar_14.dart 147 lines
// Ashlar UI block: sidebar-14. Copied into your app by `ashlar add sidebar-14`; it is yours
// to change. A table of contents on the right of the page.
import 'package:flutter/widgets.dart';
import '../ui/breadcrumb.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample contents: sections of pages.
const _sections = [
('Getting Started', ['Installation', 'Project Structure']),
(
'Build Your App',
['Routing', 'Data Fetching', 'Rendering', 'State', 'Styling'],
),
('API Reference', ['Components', 'Widgets', 'Functions', 'CLI']),
('Architecture', ['Layers', 'Hot Reload', 'Platform Channels']),
];
/// A sidebar on the right.
class Sidebar14 extends StatefulWidget {
const Sidebar14({super.key});
@override
State<Sidebar14> createState() => _Sidebar14State();
}
class _Sidebar14State extends State<Sidebar14> {
var _page = 'Data Fetching';
@override
Widget build(BuildContext context) => SidebarLayout(
side: SidebarSide.end,
collapse: SidebarCollapse.offcanvas,
groups: [
SidebarGroup(label: 'Table of Contents', [
for (final (section, pages) in _sections)
SidebarItem(
section,
icon: null,
initiallyOpen: true,
children: [
for (final page in pages)
SidebarItem(
page,
icon: null,
active: page == _page,
onPressed: () => setState(() => _page = page),
),
],
),
]),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Breadcrumb(
items: [
BreadcrumbItem(
_sections.firstWhere((s) => s.$2.contains(_page)).$1,
onPressed: () {},
),
BreadcrumbItem(_page),
],
),
),
const SizedBox(
height: 16,
child: Separator(axis: Axis.vertical),
),
const SizedBox(width: 8),
const SidebarTrigger(),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
),
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-15
A sidebar on each side: pages on the left, a calendar on the right.
ashlar add sidebar-15
lib/blocks/sidebar_15.dart 461 lines
// Ashlar UI block: sidebar-15. Copied into your app by `ashlar add sidebar-15`; it is yours
// to change. A sidebar on each side: pages on the left, folding away; a calendar and the
// calendars to show on the right, staying put.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/calendar.dart';
import '../ui/checkbox.dart';
import '../ui/dropdown_menu.dart';
import '../ui/field.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample pages and workspaces.
const _favorites = [
'Project Management & Task Tracking',
'Family Recipe Collection & Meal Planning',
'Fitness Tracker & Workout Routines',
];
const _workspaces = [
(
'Personal Life Management',
['Daily Journal & Reflection', 'Health & Wellness Tracker'],
),
(
'Professional Development',
['Career Objectives & Milestones', 'Skill Acquisition Plan'],
),
];
/// A sidebar on the left and another on the right.
class Sidebar15 extends StatefulWidget {
const Sidebar15({super.key});
@override
State<Sidebar15> createState() => _Sidebar15State();
}
class _Sidebar15State extends State<Sidebar15> {
var _day = DateTime(2026, 10, 13);
@override
Widget build(BuildContext context) => SidebarLayout(
header: const _TeamSwitcher(),
groups: [
SidebarGroup([
SidebarItem(
'Search',
icon: AshlarIconsRoundedStroke.search,
onPressed: () {},
),
SidebarItem(
'Ask AI',
icon: AshlarIconsRoundedStroke.sparkles,
onPressed: () {},
),
SidebarItem(
'Home',
icon: AshlarIconsRoundedStroke.house,
active: true,
onPressed: () {},
),
SidebarItem(
'Inbox',
icon: AshlarIconsRoundedStroke.inbox,
badge: '10',
onPressed: () {},
),
]),
SidebarGroup(label: 'Favorites', [
for (final page in _favorites)
SidebarItem(
page,
icon: AshlarIconsRoundedStroke.fileText,
onPressed: () {},
),
]),
SidebarGroup(label: 'Workspaces', [
for (final (name, pages) in _workspaces)
SidebarItem(
name,
icon: AshlarIconsRoundedStroke.folder,
children: [
for (final page in pages)
SidebarItem(page, icon: null, onPressed: () {}),
],
),
]),
SidebarGroup(atEnd: true, [
SidebarItem(
'Settings',
icon: AshlarIconsRoundedStroke.settings,
onPressed: () {},
),
SidebarItem(
'Help',
icon: AshlarIconsRoundedStroke.circleQuestionMark,
onPressed: () {},
),
]),
],
child: SidebarLayout(
side: SidebarSide.end,
collapse: SidebarCollapse.none,
width: 288,
narrow: 1024,
header: const _NavUser(),
groups: [
SidebarGroup.custom(
Calendar(selected: _day, onSelected: (d) => setState(() => _day = d)),
),
const SidebarGroup.custom(Separator()),
const SidebarGroup.custom(_Calendars()),
],
footer: Button(
variant: ButtonVariant.ghost,
expand: true,
leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
onPressed: () {},
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: Text('New Calendar'),
),
),
child: _Page(trail: [_favorites.first]),
),
);
}
/// The team you are working in, and a menu to change it.
class _TeamSwitcher extends StatefulWidget {
const _TeamSwitcher();
@override
State<_TeamSwitcher> createState() => _TeamSwitcherState();
}
class _TeamSwitcherState extends State<_TeamSwitcher> {
// Sample teams.
static const _teams = [
('Acme Inc', 'Enterprise', AshlarIconsRoundedStroke.galleryVerticalEnd),
('Acme Corp.', 'Startup', AshlarIconsRoundedStroke.audioLines),
('Evil Corp.', 'Free', AshlarIconsRoundedStroke.command),
];
var _team = 0;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final (name, plan, icon) = _teams[_team];
return DropdownMenu(
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Team: $name',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.sidebarPrimary,
borderRadius: BorderRadius.circular(t.radiusLg),
),
child: AshlarIcon(icon, color: c.sidebarPrimaryForeground),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(plan, style: t.text.xs),
],
),
),
],
),
),
items: [
const MenuLabel(child: Text('Teams')),
for (final (i, (name, _, icon)) in _teams.indexed)
MenuItem(
leading: AshlarIcon(icon),
shortcut: Text('⌘${i + 1}'),
onPressed: () => setState(() => _team = i),
child: Text(name),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
onPressed: () {},
child: const Text('Add team'),
),
],
);
}
}
/// Sample calendars, in groups.
const _calendars = [
('My Calendars', ['Personal', 'Work', 'Family']),
('Favorites', ['Holidays', 'Birthdays']),
('Other', ['Travel', 'Reminders', 'Deadlines']),
];
/// The calendars to show, by group, each group folding away.
class _Calendars extends StatefulWidget {
const _Calendars();
@override
State<_Calendars> createState() => _CalendarsState();
}
class _CalendarsState extends State<_Calendars> {
final _shown = {'Personal', 'Work', 'Holidays'};
final _open = {'My Calendars'};
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (group, names) in _calendars) ...[
Button(
variant: ButtonVariant.ghost,
size: ButtonSize.sm,
expand: true,
onPressed: () => setState(
() => _open.contains(group)
? _open.remove(group)
: _open.add(group),
),
trailing: AnimatedRotation(
turns: _open.contains(group) ? .25 : 0,
duration: t.duration,
child: AshlarIcon(
isRtl(context)
? AshlarIconsRoundedStroke.chevronLeft
: AshlarIconsRoundedStroke.chevronRight,
),
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(group),
),
),
if (_open.contains(group))
for (final name in names)
Padding(
padding: const EdgeInsetsDirectional.only(
start: 12,
top: 4,
bottom: 4,
),
child: Field(
axis: Axis.horizontal,
label: Text(name),
child: Checkbox(
value: _shown.contains(name),
onChanged: (v) => setState(
() => v ? _shown.add(name) : _shown.remove(name),
),
),
),
),
],
],
);
}
}
/// Who is signed in, and a menu for their account.
class _NavUser extends StatelessWidget {
const _NavUser();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.sparkles),
onPressed: () {},
child: const Text('Upgrade to Pro'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.badgeCheck),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
/// The page beside the sidebar: the trigger and where you are along the top, and room
/// for your content under it.
class _Page extends StatelessWidget {
const _Page({required this.trail});
/// Where you are, from the top down; the last is this page.
final List<String> trail;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const SidebarTrigger(),
const SizedBox(width: 8),
const SizedBox(height: 16, child: Separator(axis: Axis.vertical)),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
for (final (i, label) in trail.indexed)
BreadcrumbItem(
label,
onPressed: i == trail.length - 1 ? null : () {},
),
],
),
),
],
),
),
),
const Separator(),
const Expanded(child: _Placeholder()),
],
);
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
sidebar-16
A site header across the top, the sidebar under it.
ashlar add sidebar-16
lib/blocks/sidebar_16.dart 275 lines
// Ashlar UI block: sidebar-16. Copied into your app by `ashlar add sidebar-16`; it is yours
// to change. A site header across the top that stays as the page scrolls, and the sidebar
// under it.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/avatar.dart';
import '../ui/breadcrumb.dart';
import '../ui/button.dart';
import '../ui/dropdown_menu.dart';
import '../ui/input.dart';
import '../ui/popover.dart';
import '../ui/separator.dart';
import '../ui/sidebar.dart';
import '../ui/theme.dart';
/// Sample sections, each with its pages.
const _platform = [
(
'Playground',
AshlarIconsRoundedStroke.terminal,
['History', 'Starred', 'Settings'],
),
('Models', AshlarIconsRoundedStroke.bot, ['Genesis', 'Explorer', 'Quantum']),
(
'Documentation',
AshlarIconsRoundedStroke.bookOpen,
['Introduction', 'Get Started', 'Tutorials', 'Changelog'],
),
(
'Settings',
AshlarIconsRoundedStroke.settings,
['General', 'Team', 'Billing', 'Limits'],
),
];
/// Sample projects.
const _projects = [
('Design Engineering', AshlarIconsRoundedStroke.frame),
('Sales & Marketing', AshlarIconsRoundedStroke.chartPie),
('Travel', AshlarIconsRoundedStroke.map),
];
/// A site header over a sidebar and its page.
class Sidebar16 extends StatefulWidget {
const Sidebar16({super.key});
@override
State<Sidebar16> createState() => _Sidebar16State();
}
class _Sidebar16State extends State<Sidebar16> {
// The header is above the sidebar, so its button reaches the sidebar by this key.
final _sidebar = GlobalKey<SidebarLayoutState>();
@override
Widget build(BuildContext context) {
final wide = MediaQuery.sizeOf(context).width >= 640;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 56,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Button(
variant: ButtonVariant.ghost,
size: ButtonSize.icon,
semanticLabel: 'Toggle sidebar',
onPressed: () => _sidebar.currentState?.toggle(),
child: const AshlarIcon(AshlarIconsRoundedStroke.panelLeft),
),
const SizedBox(width: 8),
const SizedBox(
height: 16,
child: Separator(axis: Axis.vertical),
),
const SizedBox(width: 8),
Expanded(
child: Breadcrumb(
items: [
BreadcrumbItem('Build Your App', onPressed: () {}),
const BreadcrumbItem('Data Fetching'),
],
),
),
if (wide)
const SizedBox(
width: 240,
child: Input(
placeholder: 'Type to search…',
semanticLabel: 'Search',
leading: AshlarIcon(AshlarIconsRoundedStroke.search),
),
),
],
),
),
),
const Separator(),
Expanded(
child: SidebarLayout(
key: _sidebar,
groups: [
SidebarGroup(label: 'Platform', [
for (final (i, (label, icon, pages)) in _platform.indexed)
SidebarItem(
label,
icon: icon,
initiallyOpen: i == 0,
children: [
for (final page in pages)
SidebarItem(page, icon: null, onPressed: () {}),
],
),
]),
SidebarGroup(label: 'Projects', [
for (final (label, icon) in _projects)
SidebarItem(label, icon: icon, onPressed: () {}),
]),
SidebarGroup(atEnd: true, [
SidebarItem(
'Support',
icon: AshlarIconsRoundedStroke.lifeBuoy,
onPressed: () {},
),
SidebarItem(
'Feedback',
icon: AshlarIconsRoundedStroke.send,
onPressed: () {},
),
]),
],
footer: const _NavUser(),
child: const _Placeholder(),
),
),
],
);
}
}
/// Who is signed in, and a menu for their account.
class _NavUser extends StatelessWidget {
const _NavUser();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return DropdownMenu(
side: PopoverSide.top,
width: 224,
builder: (context, menu) => Button(
variant: ButtonVariant.ghost,
expand: true,
semanticLabel: 'Account: Wanjiru Kamau',
onPressed: menu.open,
trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronsUpDown),
child: Row(
children: [
const Avatar(fallback: 'WK'),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wanjiru Kamau',
overflow: TextOverflow.ellipsis,
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
Text(
'wanjiru@solutech.co.ke',
overflow: TextOverflow.ellipsis,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
],
),
),
items: [
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.sparkles),
onPressed: () {},
child: const Text('Upgrade to Pro'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.badgeCheck),
onPressed: () {},
child: const Text('Account'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
onPressed: () {},
child: const Text('Billing'),
),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.bell),
onPressed: () {},
child: const Text('Notifications'),
),
const MenuSeparator(),
MenuItem(
leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
onPressed: () {},
child: const Text('Log out'),
),
],
);
}
}
/// Grey shapes where the page's content goes.
class _Placeholder extends StatelessWidget {
const _Placeholder();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final box = BoxDecoration(
color: t.colors.muted.withValues(alpha: .5),
borderRadius: BorderRadius.circular(t.radiusXl),
);
return LayoutBuilder(
builder: (context, size) => size.maxWidth >= 768
? Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
child: AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
),
],
],
),
const SizedBox(height: 16),
Expanded(
child: SizedBox.expand(
child: DecoratedBox(decoration: box),
),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
for (var i = 0; i < 3; i++) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(decoration: box),
),
const SizedBox(height: 16),
],
SizedBox(height: 480, child: DecoratedBox(decoration: box)),
],
),
);
}
}
Logins
Sign-in screens, each with its form checked as it is sent.
login-01
A sign-in card in the middle of the page.
ashlar add login-01
lib/blocks/login_01.dart 119 lines
// Ashlar UI block: login-01. Copied into your app by `ashlar add login-01`; it is yours to
// change. A sign-in card in the middle of the page.
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/form.dart';
/// A sign-in card: email and password, checked when sent.
class Login01 extends StatefulWidget {
const Login01({
super.key,
this.onLogin,
this.onForgot,
this.onSignUp,
this.onGoogle,
});
/// Told the email and password once both are right.
final void Function(String email, String password)? onLogin;
final VoidCallback? onForgot, onSignUp, onGoogle;
@override
State<Login01> createState() => _Login01State();
}
class _Login01State extends State<Login01> {
final _form = GlobalKey<FormState>();
var _email = '', _password = '';
void _send() {
if (_form.currentState!.submit()) widget.onLogin?.call(_email, _password);
}
@override
Widget build(BuildContext context) => Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Card(
children: [
const CardHeader(
title: Text('Login to your account'),
description: Text(
'Enter your email below to login to your account',
),
),
CardContent(
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormText(
label: const Text('Email'),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Password'),
obscureText: true,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _send(),
validator: Validate.required('Enter your password.'),
onSaved: (v) => _password = v!,
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: Button(
variant: ButtonVariant.link,
onPressed: widget.onForgot,
child: const Text('Forgot your password?'),
),
),
const SizedBox(height: 8),
Button(
expand: true,
onPressed: _send,
child: const Text('Login'),
),
const SizedBox(height: 12),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Login with Google'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text("Don't have an account?"),
Button(
variant: ButtonVariant.link,
onPressed: widget.onSignUp,
child: const Text('Sign up'),
),
],
),
],
),
),
),
],
),
),
),
);
}
login-02
Sign in on one side, a picture on the other.
ashlar add login-02
lib/blocks/login_02.dart 220 lines
// Ashlar UI block: login-02. Copied into your app by `ashlar add login-02`; it is yours to
// change. Two columns: the form on one side, a picture on the other. On a narrow screen
// only the form shows.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-in page beside a picture.
class Login02 extends StatefulWidget {
const Login02({
super.key,
this.picture,
this.onLogin,
this.onForgot,
this.onSignUp,
this.onGoogle,
});
/// Shown beside the form on a wide screen: `Image.asset('assets/login.jpg', fit: BoxFit.cover)`.
final Widget? picture;
/// Told the email and password once both are right.
final void Function(String email, String password)? onLogin;
final VoidCallback? onForgot, onSignUp, onGoogle;
@override
State<Login02> createState() => _Login02State();
}
class _Login02State extends State<Login02> {
final _form = GlobalKey<FormState>();
var _email = '', _password = '';
void _send() {
if (_form.currentState!.submit()) widget.onLogin?.call(_email, _password);
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final form = Form(
key: _form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Login to your account',
textAlign: TextAlign.center,
style: t.text.xxl,
),
const SizedBox(height: 8),
Text(
'Enter your email below to login to your account',
textAlign: TextAlign.center,
style: t.text.sm.copyWith(color: c.mutedForeground),
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Password'),
obscureText: true,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _send(),
validator: Validate.required('Enter your password.'),
onSaved: (v) => _password = v!,
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: Button(
variant: ButtonVariant.link,
onPressed: widget.onForgot,
child: const Text('Forgot your password?'),
),
),
const SizedBox(height: 8),
Button(expand: true, onPressed: _send, child: const Text('Login')),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Login with Google'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text("Don't have an account?"),
Button(
variant: ButtonVariant.link,
onPressed: widget.onSignUp,
child: const Text('Sign up'),
),
],
),
],
),
);
final side = Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Logo(),
Expanded(
child: Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: form,
),
),
),
),
],
),
);
return LayoutBuilder(
builder: (context, box) => box.maxWidth < 1024
? side
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: side),
Expanded(
child: ColoredBox(
color: c.muted,
child:
widget.picture ??
Center(
child: AshlarIcon(
AshlarIconsRoundedStroke.image,
size: 48,
color: c.mutedForeground,
),
),
),
),
],
),
);
}
}
class _Logo extends StatelessWidget {
const _Logo();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 16,
color: c.primaryForeground,
),
),
const SizedBox(width: 8),
Text(
'Acme Inc.',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
],
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
login-03
A sign-in card on a muted page, other accounts first.
ashlar add login-03
lib/blocks/login_03.dart 214 lines
// Ashlar UI block: login-03. Copied into your app by `ashlar add login-03`; it is yours to
// change. A sign-in card on a muted page: other accounts first, then email.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-in card with Apple and Google above the email form.
class Login03 extends StatefulWidget {
const Login03({
super.key,
this.onLogin,
this.onForgot,
this.onSignUp,
this.onApple,
this.onGoogle,
});
/// Told the email and password once both are right.
final void Function(String email, String password)? onLogin;
final VoidCallback? onForgot, onSignUp, onApple, onGoogle;
@override
State<Login03> createState() => _Login03State();
}
class _Login03State extends State<Login03> {
final _form = GlobalKey<FormState>();
var _email = '', _password = '';
void _send() {
if (_form.currentState!.submit()) widget.onLogin?.call(_email, _password);
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return ColoredBox(
color: c.muted,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: _Logo()),
const SizedBox(height: 24),
Card(
children: [
const CardHeader(
title: Center(child: Text('Welcome back')),
description: Center(
child: Text('Login with your Apple or Google account'),
),
),
CardContent(
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Button(
expand: true,
variant: ButtonVariant.outline,
leading: const AshlarIcon(
AshlarIconsRoundedStroke.apple,
),
onPressed: widget.onApple,
child: const Text('Login with Apple'),
),
const SizedBox(height: 12),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Login with Google'),
),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Password'),
obscureText: true,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _send(),
validator: Validate.required(
'Enter your password.',
),
onSaved: (v) => _password = v!,
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: Button(
variant: ButtonVariant.link,
onPressed: widget.onForgot,
child: const Text('Forgot your password?'),
),
),
const SizedBox(height: 8),
Button(
expand: true,
onPressed: _send,
child: const Text('Login'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text("Don't have an account?"),
Button(
variant: ButtonVariant.link,
onPressed: widget.onSignUp,
child: const Text('Sign up'),
),
],
),
],
),
),
),
],
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
),
);
}
}
class _Logo extends StatelessWidget {
const _Logo();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 16,
color: c.primaryForeground,
),
),
const SizedBox(width: 8),
Text(
'Acme Inc.',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
],
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
login-04
A sign-in card in two halves, the form and a picture.
ashlar add login-04
lib/blocks/login_04.dart 217 lines
// Ashlar UI block: login-04. Copied into your app by `ashlar add login-04`; it is yours to
// change. One card in two halves: the form, and a picture beside it on a wide screen.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-in card with a picture in its second half.
class Login04 extends StatefulWidget {
const Login04({
super.key,
this.picture,
this.onLogin,
this.onForgot,
this.onSignUp,
this.onApple,
this.onGoogle,
this.onMeta,
});
/// Shown in the card's second half on a wide screen.
final Widget? picture;
/// Told the email and password once both are right.
final void Function(String email, String password)? onLogin;
final VoidCallback? onForgot, onSignUp, onApple, onGoogle, onMeta;
@override
State<Login04> createState() => _Login04State();
}
class _Login04State extends State<Login04> {
final _form = GlobalKey<FormState>();
var _email = '', _password = '';
void _send() {
if (_form.currentState!.submit()) widget.onLogin?.call(_email, _password);
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final form = Padding(
padding: const EdgeInsets.all(32),
child: Form(
key: _form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Welcome back',
textAlign: TextAlign.center,
style: t.text.xxl,
),
const SizedBox(height: 4),
Text(
'Login to your Acme Inc account',
textAlign: TextAlign.center,
style: t.text.sm.copyWith(color: c.mutedForeground),
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Password'),
obscureText: true,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _send(),
validator: Validate.required('Enter your password.'),
onSaved: (v) => _password = v!,
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: Button(
variant: ButtonVariant.link,
onPressed: widget.onForgot,
child: const Text('Forgot your password?'),
),
),
const SizedBox(height: 8),
Button(expand: true, onPressed: _send, child: const Text('Login')),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
Row(
children: [
for (final (i, (label, onPressed)) in [
('Apple', widget.onApple),
('Google', widget.onGoogle),
('Meta', widget.onMeta),
].indexed) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
semanticLabel: 'Login with $label',
onPressed: onPressed,
child: Text(label),
),
),
],
],
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text("Don't have an account?"),
Button(
variant: ButtonVariant.link,
onPressed: widget.onSignUp,
child: const Text('Sign up'),
),
],
),
],
),
),
);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 768),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: c.card,
borderRadius: BorderRadius.circular(t.radiusXl),
border: Border.all(color: c.border),
boxShadow: t.shadowSm,
),
child: LayoutBuilder(
builder: (context, box) => box.maxWidth < 640
? form
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: form),
Expanded(
child: ColoredBox(
color: c.muted,
child:
widget.picture ??
Center(
child: AshlarIcon(
AshlarIconsRoundedStroke.image,
size: 48,
color: c.mutedForeground,
),
),
),
),
],
),
),
),
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
login-05
Sign in with an email address alone.
ashlar add login-05
lib/blocks/login_05.dart 159 lines
// Ashlar UI block: login-05. Copied into your app by `ashlar add login-05`; it is yours to
// change. Email only, for a sign-in link sent by mail, with other accounts below.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-in page that asks only for an email address.
class Login05 extends StatefulWidget {
const Login05({
super.key,
this.onLogin,
this.onSignUp,
this.onApple,
this.onGoogle,
});
/// Told the email once it is right: send the sign-in link to it.
final ValueChanged<String>? onLogin;
final VoidCallback? onSignUp, onApple, onGoogle;
@override
State<Login05> createState() => _Login05State();
}
class _Login05State extends State<Login05> {
final _form = GlobalKey<FormState>();
var _email = '';
void _send() {
if (_form.currentState!.submit()) widget.onLogin?.call(_email);
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 20,
color: c.primaryForeground,
),
),
),
const SizedBox(height: 8),
Text(
'Welcome to Acme Inc.',
textAlign: TextAlign.center,
style: t.text.xl.copyWith(fontWeight: FontWeight.w700),
),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text("Don't have an account?"),
Button(
variant: ButtonVariant.link,
onPressed: widget.onSignUp,
child: const Text('Sign up'),
),
],
),
const SizedBox(height: 16),
FormText(
label: const Text('Email'),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmitted: (_) => _send(),
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Login'),
),
const SizedBox(height: 24),
Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or',
style: t.text.sm.copyWith(color: c.mutedForeground),
),
),
const Expanded(child: Separator()),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
leading: const AshlarIcon(
AshlarIconsRoundedStroke.apple,
),
onPressed: widget.onApple,
semanticLabel: 'Continue with Apple',
child: const Text('Apple'),
),
),
const SizedBox(width: 12),
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
semanticLabel: 'Continue with Google',
child: const Text('Google'),
),
),
],
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
),
);
}
}
Signups
Sign-up screens, the passwords checked against each other.
signup-01
A sign-up card in the middle of the page.
ashlar add signup-01
lib/blocks/signup_01.dart 140 lines
// Ashlar UI block: signup-01. Copied into your app by `ashlar add signup-01`; it is yours to
// change. A sign-up card in the middle of the page.
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/form.dart';
/// A sign-up card: email and password, checked when sent.
class Signup01 extends StatefulWidget {
const Signup01({super.key, this.onSignUp, this.onLogin, this.onGoogle});
/// Told the name, email and password once all are right.
final void Function(String name, String email, String password)? onSignUp;
final VoidCallback? onLogin, onGoogle;
@override
State<Signup01> createState() => _Signup01State();
}
class _Signup01State extends State<Signup01> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
var _name = '', _email = '';
@override
void dispose() {
_password.dispose();
super.dispose();
}
void _send() {
if (_form.currentState!.submit()) {
widget.onSignUp?.call(_name, _email, _password.text);
}
}
@override
Widget build(BuildContext context) => Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Card(
children: [
const CardHeader(
title: Text('Create your account'),
description: Text(
'Enter your details below to create your account',
),
),
CardContent(
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormText(
label: const Text('Full Name'),
placeholder: 'Wanjiru Kamau',
autofillHints: const [AutofillHints.name],
validator: Validate.required('Enter your name.'),
onSaved: (v) => _name = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
description: const Text(
"We'll use this to contact you. We will not share your email with anyone else.",
),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
controller: _password,
label: const Text('Password'),
description: const Text(
'Must be at least 8 characters long.',
),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
validator: Validate.all([
Validate.required('Choose a password.'),
Validate.minLength(8),
]),
),
const SizedBox(height: 24),
FormText(
label: const Text('Confirm Password'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _send(),
validator: (v) => v == _password.text
? null
: 'The passwords do not match.',
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Create Account'),
),
const SizedBox(height: 12),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Sign up with Google'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('Already have an account?'),
Button(
variant: ButtonVariant.link,
onPressed: widget.onLogin,
child: const Text('Sign in'),
),
],
),
],
),
),
),
],
),
),
),
);
}
signup-02
Sign up on one side, a picture on the other.
ashlar add signup-02
lib/blocks/signup_02.dart 247 lines
// Ashlar UI block: signup-02. Copied into your app by `ashlar add signup-02`; it is yours to
// change. Two columns: the form on one side, a picture on the other. On a narrow screen
// only the form shows.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-up page beside a picture.
class Signup02 extends StatefulWidget {
const Signup02({
super.key,
this.picture,
this.onSignUp,
this.onLogin,
this.onGoogle,
});
/// Shown beside the form on a wide screen: `Image.asset('assets/signup.jpg', fit: BoxFit.cover)`.
final Widget? picture;
/// Told the name, email and password once all are right.
final void Function(String name, String email, String password)? onSignUp;
final VoidCallback? onLogin, onGoogle;
@override
State<Signup02> createState() => _Signup02State();
}
class _Signup02State extends State<Signup02> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
var _name = '', _email = '';
@override
void dispose() {
_password.dispose();
super.dispose();
}
void _send() {
if (_form.currentState!.submit()) {
widget.onSignUp?.call(_name, _email, _password.text);
}
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final form = Form(
key: _form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Create your account',
textAlign: TextAlign.center,
style: t.text.xxl,
),
const SizedBox(height: 8),
Text(
'Enter your details below to create your account',
textAlign: TextAlign.center,
style: t.text.sm.copyWith(color: c.mutedForeground),
),
const SizedBox(height: 24),
FormText(
label: const Text('Full Name'),
placeholder: 'Wanjiru Kamau',
autofillHints: const [AutofillHints.name],
validator: Validate.required('Enter your name.'),
onSaved: (v) => _name = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
description: const Text(
"We'll use this to contact you. We will not share your email with anyone else.",
),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
controller: _password,
label: const Text('Password'),
description: const Text('Must be at least 8 characters long.'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
validator: Validate.all([
Validate.required('Choose a password.'),
Validate.minLength(8),
]),
),
const SizedBox(height: 24),
FormText(
label: const Text('Confirm Password'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _send(),
validator: (v) =>
v == _password.text ? null : 'The passwords do not match.',
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Create Account'),
),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Sign up with Google'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('Already have an account?'),
Button(
variant: ButtonVariant.link,
onPressed: widget.onLogin,
child: const Text('Sign in'),
),
],
),
],
),
);
final side = Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Logo(),
Expanded(
child: Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: form,
),
),
),
),
],
),
);
return LayoutBuilder(
builder: (context, box) => box.maxWidth < 1024
? side
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: side),
Expanded(
child: ColoredBox(
color: c.muted,
child:
widget.picture ??
Center(
child: AshlarIcon(
AshlarIconsRoundedStroke.image,
size: 48,
color: c.mutedForeground,
),
),
),
),
],
),
);
}
}
class _Logo extends StatelessWidget {
const _Logo();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 16,
color: c.primaryForeground,
),
),
const SizedBox(width: 8),
Text(
'Acme Inc.',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
],
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
signup-03
A sign-up card on a muted page, other accounts first.
ashlar add signup-03
lib/blocks/signup_03.dart 240 lines
// Ashlar UI block: signup-03. Copied into your app by `ashlar add signup-03`; it is yours to
// change. A sign-up card on a muted page: other accounts first, then email.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/card.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-up card with Apple and Google above the email form.
class Signup03 extends StatefulWidget {
const Signup03({
super.key,
this.onSignUp,
this.onLogin,
this.onApple,
this.onGoogle,
});
/// Told the name, email and password once all are right.
final void Function(String name, String email, String password)? onSignUp;
final VoidCallback? onLogin, onApple, onGoogle;
@override
State<Signup03> createState() => _Signup03State();
}
class _Signup03State extends State<Signup03> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
var _name = '', _email = '';
@override
void dispose() {
_password.dispose();
super.dispose();
}
void _send() {
if (_form.currentState!.submit()) {
widget.onSignUp?.call(_name, _email, _password.text);
}
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return ColoredBox(
color: c.muted,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: _Logo()),
const SizedBox(height: 24),
Card(
children: [
const CardHeader(
title: Center(child: Text('Create your account')),
description: Center(
child: Text(
'Enter your details below to create your account',
),
),
),
CardContent(
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Button(
expand: true,
variant: ButtonVariant.outline,
leading: const AshlarIcon(
AshlarIconsRoundedStroke.apple,
),
onPressed: widget.onApple,
child: const Text('Sign up with Apple'),
),
const SizedBox(height: 12),
Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
child: const Text('Sign up with Google'),
),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
FormText(
label: const Text('Full Name'),
placeholder: 'Wanjiru Kamau',
autofillHints: const [AutofillHints.name],
validator: Validate.required('Enter your name.'),
onSaved: (v) => _name = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
description: const Text(
"We'll use this to contact you. We will not share your email with anyone else.",
),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
controller: _password,
label: const Text('Password'),
description: const Text(
'Must be at least 8 characters long.',
),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
validator: Validate.all([
Validate.required('Choose a password.'),
Validate.minLength(8),
]),
),
const SizedBox(height: 24),
FormText(
label: const Text('Confirm Password'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _send(),
validator: (v) => v == _password.text
? null
: 'The passwords do not match.',
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Create Account'),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('Already have an account?'),
Button(
variant: ButtonVariant.link,
onPressed: widget.onLogin,
child: const Text('Sign in'),
),
],
),
],
),
),
),
],
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
),
);
}
}
class _Logo extends StatelessWidget {
const _Logo();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 16,
color: c.primaryForeground,
),
),
const SizedBox(width: 8),
Text(
'Acme Inc.',
style: t.text.sm.copyWith(fontWeight: FontWeight.w500),
),
],
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
signup-04
A sign-up card in two halves, the form and a picture.
ashlar add signup-04
lib/blocks/signup_04.dart 244 lines
// Ashlar UI block: signup-04. Copied into your app by `ashlar add signup-04`; it is yours to
// change. One card in two halves: the form, and a picture beside it on a wide screen.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-up card with a picture in its second half.
class Signup04 extends StatefulWidget {
const Signup04({
super.key,
this.picture,
this.onSignUp,
this.onLogin,
this.onApple,
this.onGoogle,
this.onMeta,
});
/// Shown in the card's second half on a wide screen.
final Widget? picture;
/// Told the name, email and password once all are right.
final void Function(String name, String email, String password)? onSignUp;
final VoidCallback? onLogin, onApple, onGoogle, onMeta;
@override
State<Signup04> createState() => _Signup04State();
}
class _Signup04State extends State<Signup04> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
var _name = '', _email = '';
@override
void dispose() {
_password.dispose();
super.dispose();
}
void _send() {
if (_form.currentState!.submit()) {
widget.onSignUp?.call(_name, _email, _password.text);
}
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final form = Padding(
padding: const EdgeInsets.all(32),
child: Form(
key: _form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Create your account',
textAlign: TextAlign.center,
style: t.text.xxl,
),
const SizedBox(height: 4),
Text(
'Enter your details below to create your account',
textAlign: TextAlign.center,
style: t.text.sm.copyWith(color: c.mutedForeground),
),
const SizedBox(height: 24),
FormText(
label: const Text('Full Name'),
placeholder: 'Wanjiru Kamau',
autofillHints: const [AutofillHints.name],
validator: Validate.required('Enter your name.'),
onSaved: (v) => _name = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
description: const Text(
"We'll use this to contact you. We will not share your email with anyone else.",
),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
controller: _password,
label: const Text('Password'),
description: const Text('Must be at least 8 characters long.'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
validator: Validate.all([
Validate.required('Choose a password.'),
Validate.minLength(8),
]),
),
const SizedBox(height: 24),
FormText(
label: const Text('Confirm Password'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _send(),
validator: (v) =>
v == _password.text ? null : 'The passwords do not match.',
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Create Account'),
),
const SizedBox(height: 24),
const _Or(),
const SizedBox(height: 24),
Row(
children: [
for (final (i, (label, onPressed)) in [
('Apple', widget.onApple),
('Google', widget.onGoogle),
('Meta', widget.onMeta),
].indexed) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
semanticLabel: 'Sign up with $label',
onPressed: onPressed,
child: Text(label),
),
),
],
],
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('Already have an account?'),
Button(
variant: ButtonVariant.link,
onPressed: widget.onLogin,
child: const Text('Sign in'),
),
],
),
],
),
),
);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 768),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: c.card,
borderRadius: BorderRadius.circular(t.radiusXl),
border: Border.all(color: c.border),
boxShadow: t.shadowSm,
),
child: LayoutBuilder(
builder: (context, box) => box.maxWidth < 640
? form
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: form),
Expanded(
child: ColoredBox(
color: c.muted,
child:
widget.picture ??
Center(
child: AshlarIcon(
AshlarIconsRoundedStroke.image,
size: 48,
color: c.mutedForeground,
),
),
),
),
],
),
),
),
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
);
}
}
/// A line with "Or continue with" across its middle.
class _Or extends StatelessWidget {
const _Or();
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
return Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or continue with',
style: t.text.sm.copyWith(color: t.colors.mutedForeground),
),
),
const Expanded(child: Separator()),
],
);
}
}
signup-05
A sign-up page under a logo, other accounts below.
ashlar add signup-05
lib/blocks/signup_05.dart 202 lines
// Ashlar UI block: signup-05. Copied into your app by `ashlar add signup-05`; it is yours to
// change. The full sign-up form, with other accounts below.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import '../ui/button.dart';
import '../ui/form.dart';
import '../ui/separator.dart';
import '../ui/theme.dart';
/// A sign-up page under a logo, with other accounts below.
class Signup05 extends StatefulWidget {
const Signup05({
super.key,
this.onSignUp,
this.onLogin,
this.onApple,
this.onGoogle,
});
/// Told the name, email and password once all are right.
final void Function(String name, String email, String password)? onSignUp;
final VoidCallback? onLogin, onApple, onGoogle;
@override
State<Signup05> createState() => _Signup05State();
}
class _Signup05State extends State<Signup05> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
var _name = '', _email = '';
@override
void dispose() {
_password.dispose();
super.dispose();
}
void _send() {
if (_form.currentState!.submit()) {
widget.onSignUp?.call(_name, _email, _password.text);
}
}
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 384),
child: Form(
key: _form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: c.primary,
borderRadius: BorderRadius.circular(t.radiusMd),
),
child: AshlarIcon(
AshlarIconsRoundedStroke.galleryVerticalEnd,
size: 20,
color: c.primaryForeground,
),
),
),
const SizedBox(height: 8),
Text(
'Create your account',
textAlign: TextAlign.center,
style: t.text.xl.copyWith(fontWeight: FontWeight.w700),
),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('Already have an account?'),
Button(
variant: ButtonVariant.link,
onPressed: widget.onLogin,
child: const Text('Sign in'),
),
],
),
const SizedBox(height: 16),
FormText(
label: const Text('Full Name'),
placeholder: 'Wanjiru Kamau',
autofillHints: const [AutofillHints.name],
validator: Validate.required('Enter your name.'),
onSaved: (v) => _name = v!,
),
const SizedBox(height: 24),
FormText(
label: const Text('Email'),
description: const Text(
"We'll use this to contact you. We will not share your email with anyone else.",
),
placeholder: 'you@solutech.co.ke',
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
validator: Validate.all([
Validate.required('Enter your email.'),
Validate.email(),
]),
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
FormText(
controller: _password,
label: const Text('Password'),
description: const Text(
'Must be at least 8 characters long.',
),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
validator: Validate.all([
Validate.required('Choose a password.'),
Validate.minLength(8),
]),
),
const SizedBox(height: 24),
FormText(
label: const Text('Confirm Password'),
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _send(),
validator: (v) => v == _password.text
? null
: 'The passwords do not match.',
),
const SizedBox(height: 24),
Button(
expand: true,
onPressed: _send,
child: const Text('Create Account'),
),
const SizedBox(height: 24),
Row(
children: [
const Expanded(child: Separator()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Or',
style: t.text.sm.copyWith(color: c.mutedForeground),
),
),
const Expanded(child: Separator()),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
leading: const AshlarIcon(
AshlarIconsRoundedStroke.apple,
),
onPressed: widget.onApple,
semanticLabel: 'Continue with Apple',
child: const Text('Apple'),
),
),
const SizedBox(width: 12),
Expanded(
child: Button(
expand: true,
variant: ButtonVariant.outline,
onPressed: widget.onGoogle,
semanticLabel: 'Continue with Google',
child: const Text('Google'),
),
),
],
),
const SizedBox(height: 24),
Text(
'By clicking continue, you agree to our Terms of Service and '
'Privacy Policy.',
textAlign: TextAlign.center,
style: t.text.xs.copyWith(color: c.mutedForeground),
),
],
),
),
),
),
);
}
}