happenings_ui 0.2.4
happenings_ui: ^0.2.4 copied to clipboard
Happenings design system — tokens, theme, and shared components for Flutter apps built on Material 3 with a Zinc color palette.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:happenings_ui/happenings_ui.dart';
void main() => runApp(const ExampleApp());
/// Example app that demonstrates how to use happenings_ui with a custom icon
/// library (Font Awesome). The [HappeningsUiConfig] at the root wires up a
/// custom [iconBuilder] and [icons] — every component in the tree automatically
/// picks up the Font Awesome icons instead of Material Icons.
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return HappeningsUiConfig(
// Swap the icon renderer to Font Awesome.
iconBuilder: (IconData icon, {double? size, Color? color}) =>
FaIcon(icon, size: size, color: color),
// Map semantic icon slots to Font Awesome glyphs.
icons: const HappeningsIcons(
back: FontAwesomeIcons.chevronLeft,
close: FontAwesomeIcons.xmark,
check: FontAwesomeIcons.check,
checkCircle: FontAwesomeIcons.solidCircleCheck,
chevronDown: FontAwesomeIcons.chevronDown,
chevronRight: FontAwesomeIcons.chevronRight,
user: FontAwesomeIcons.user,
image: FontAwesomeIcons.image,
refresh: FontAwesomeIcons.arrowsRotate,
minus: FontAwesomeIcons.minus,
info: FontAwesomeIcons.circleInfo,
warning: FontAwesomeIcons.triangleExclamation,
error: FontAwesomeIcons.lock,
exclamation: FontAwesomeIcons.circleExclamation,
),
child: MaterialApp(
title: 'happenings_ui',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: const _GalleryShell(),
),
);
}
}
// ---------------------------------------------------------------------------
// Shell — bottom nav with gallery pages
// ---------------------------------------------------------------------------
class _GalleryShell extends StatefulWidget {
const _GalleryShell();
@override
State<_GalleryShell> createState() => _GalleryShellState();
}
class _GalleryShellState extends State<_GalleryShell> {
int _tab = 0;
static const List<Widget> _pages = <Widget>[
_ComponentsPage(),
_FormsPage(),
_FeedbackPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _tab, children: _pages),
bottomNavigationBar: HappeningsBottomNavBar(
currentIndex: _tab,
onTap: (int i) => setState(() => _tab = i),
items: const <HappeningsNavItem>[
HappeningsNavItem(
icon: FontAwesomeIcons.shapes,
activeIcon: FontAwesomeIcons.shapes,
label: 'Components',
),
HappeningsNavItem(
icon: FontAwesomeIcons.penToSquare,
activeIcon: FontAwesomeIcons.solidPenToSquare,
label: 'Forms',
),
HappeningsNavItem(
icon: FontAwesomeIcons.bell,
activeIcon: FontAwesomeIcons.solidBell,
label: 'Feedback',
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Components page — buttons, cards, avatars, badges, shimmer, typography
// ---------------------------------------------------------------------------
class _ComponentsPage extends StatelessWidget {
const _ComponentsPage();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(
HapSpacing.lg,
60,
HapSpacing.lg,
HapSpacing.xxxl,
),
children: <Widget>[
const H1('Components'),
const SizedBox(height: HapSpacing.xxl),
// -- Typography --
const _SectionLabel('Typography'),
const SizedBox(height: HapSpacing.sm),
const H1('H1 — Page title'),
const SizedBox(height: HapSpacing.xs),
const H2('H2 — Section heading'),
const SizedBox(height: HapSpacing.xs),
const H3('H3 — Card title'),
const SizedBox(height: HapSpacing.xs),
const Body('Body — Default body text'),
const SizedBox(height: HapSpacing.xs),
const BodyMedium('BodyMedium — Emphasized body'),
const SizedBox(height: HapSpacing.xs),
const Caption('Caption — Small label'),
const SizedBox(height: HapSpacing.xs),
const Overline('OVERLINE — TAG'),
const SizedBox(height: HapSpacing.xxl),
// -- Buttons --
const _SectionLabel('Buttons'),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Primary button',
onPressed: () {},
),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Secondary button',
variant: HappeningsButtonVariant.secondary,
onPressed: () {},
),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Ghost button',
variant: HappeningsButtonVariant.ghost,
onPressed: () {},
),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'With icon',
icon: FontAwesomeIcons.plus,
onPressed: () {},
),
const SizedBox(height: HapSpacing.sm),
const HappeningsButton(label: 'Disabled'),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Loading...',
isLoading: true,
onPressed: () {},
),
const SizedBox(height: HapSpacing.xxl),
// -- Cards --
const _SectionLabel('Card'),
const SizedBox(height: HapSpacing.sm),
HappeningsCard(
onTap: () {},
child: const Row(
children: <Widget>[
HappeningsAvatar(name: 'Ada Lovelace', size: 44),
SizedBox(width: HapSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
H3('Ada Lovelace'),
SizedBox(height: 2),
Caption('Tap this card — feel the scale-down'),
],
),
),
],
),
),
const SizedBox(height: HapSpacing.xxl),
// -- Avatars --
const _SectionLabel('Avatars'),
const SizedBox(height: HapSpacing.sm),
const Row(
children: <Widget>[
HappeningsAvatar(name: 'Rasmus Jensing', size: 48),
SizedBox(width: HapSpacing.sm),
HappeningsAvatar(name: 'Ada Lovelace', size: 48),
SizedBox(width: HapSpacing.sm),
HappeningsAvatar(size: 48),
SizedBox(width: HapSpacing.sm),
HappeningsAvatar(name: 'K', size: 36),
SizedBox(width: HapSpacing.sm),
HappeningsAvatar(name: 'Small', size: 28),
],
),
const SizedBox(height: HapSpacing.xxl),
// -- Badges --
const _SectionLabel('Badges'),
const SizedBox(height: HapSpacing.sm),
Wrap(
spacing: HapSpacing.sm,
runSpacing: HapSpacing.sm,
children: <Widget>[
const HappeningsBadge(label: 'Default'),
HappeningsBadge(
label: 'Success',
backgroundColor: BrandColors.green.withValues(alpha: 0.15),
textColor: BrandColors.green700,
),
HappeningsBadge(
label: 'Info',
backgroundColor: BrandColors.blue.withValues(alpha: 0.15),
textColor: BrandColors.blue700,
),
HappeningsBadge(
label: 'Warning',
backgroundColor: BrandColors.amber500.withValues(alpha: 0.15),
textColor: BrandColors.amber700,
),
],
),
const SizedBox(height: HapSpacing.xxl),
// -- Shimmer --
const _SectionLabel('Shimmer loading'),
const SizedBox(height: HapSpacing.sm),
const Row(
children: <Widget>[
HappeningsShimmer(width: 48, height: 48, borderRadius: 999),
SizedBox(width: HapSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
HappeningsShimmer(height: 14, borderRadius: 4),
SizedBox(height: HapSpacing.sm),
HappeningsShimmer(height: 14, borderRadius: 4),
],
),
),
],
),
const SizedBox(height: HapSpacing.sm),
const HappeningsShimmer(height: 120),
const SizedBox(height: HapSpacing.xxl),
// -- Empty state --
const _SectionLabel('Empty state'),
const SizedBox(height: HapSpacing.sm),
HappeningsCard(
child: HappeningsEmptyState(
icon: FontAwesomeIcons.magnifyingGlass,
title: 'No results',
subtitle: 'Try a different search term.',
actionLabel: 'Clear search',
onAction: () {},
),
),
const SizedBox(height: HapSpacing.xxl),
// -- Swipe to confirm --
const _SectionLabel('Swipe to confirm'),
const SizedBox(height: HapSpacing.sm),
SwipeToConfirmButton.light(
label: 'Swipe to confirm',
onConfirmed: () async {
await Future<void>.delayed(const Duration(seconds: 1));
return true;
},
),
],
);
}
}
// ---------------------------------------------------------------------------
// Forms page — inputs, textarea, select, OTP
// ---------------------------------------------------------------------------
class _FormsPage extends StatefulWidget {
const _FormsPage();
@override
State<_FormsPage> createState() => _FormsPageState();
}
class _FormsPageState extends State<_FormsPage> {
String? _selectedFruit;
// OTP state — 6 controllers + 6 focus nodes.
final List<TextEditingController> _otpControllers =
List<TextEditingController>.generate(6, (_) => TextEditingController());
final List<FocusNode> _otpFocusNodes =
List<FocusNode>.generate(6, (_) => FocusNode());
@override
void dispose() {
for (final TextEditingController c in _otpControllers) {
c.dispose();
}
for (final FocusNode n in _otpFocusNodes) {
n.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(
HapSpacing.lg,
60,
HapSpacing.lg,
HapSpacing.xxxl,
),
children: <Widget>[
const H1('Forms'),
const SizedBox(height: HapSpacing.xxl),
// -- Input --
const _SectionLabel('Input'),
const SizedBox(height: HapSpacing.sm),
const HappeningsInput(
label: 'Name',
hint: 'Enter your name',
),
const SizedBox(height: HapSpacing.lg),
HappeningsInput(
label: 'Email',
hint: 'you@example.com',
prefixIcon: FontAwesomeIcons.envelope,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: HapSpacing.lg),
const HappeningsInput(
label: 'With error',
hint: 'Something went wrong',
errorText: 'This field is required',
),
const SizedBox(height: HapSpacing.xxl),
// -- Textarea --
const _SectionLabel('Textarea'),
const SizedBox(height: HapSpacing.sm),
const HappeningsTextarea(
label: 'Bio',
hint: 'Tell us about yourself...',
maxLines: 4,
maxLength: 200,
),
const SizedBox(height: HapSpacing.xxl),
// -- Select --
const _SectionLabel('Select'),
const SizedBox(height: HapSpacing.sm),
HappeningsSelect<String>(
label: 'Favourite fruit',
placeholder: 'Choose one',
value: _selectedFruit,
options: const <HappeningsSelectOption<String>>[
HappeningsSelectOption(value: 'apple', label: 'Apple'),
HappeningsSelectOption(value: 'banana', label: 'Banana'),
HappeningsSelectOption(value: 'cherry', label: 'Cherry'),
HappeningsSelectOption(value: 'mango', label: 'Mango'),
],
onChanged: (String v) => setState(() => _selectedFruit = v),
),
const SizedBox(height: HapSpacing.xxl),
// -- OTP --
const _SectionLabel('OTP input'),
const SizedBox(height: HapSpacing.sm),
HappeningsOtpInput(
controllers: _otpControllers,
focusNodes: _otpFocusNodes,
onCompleted: () {
final String code =
_otpControllers.map((TextEditingController c) => c.text).join();
showHappeningsToast(
context,
title: 'Code entered',
description: code,
icon: FontAwesomeIcons.solidCircleCheck,
);
},
),
],
);
}
}
// ---------------------------------------------------------------------------
// Feedback page — toasts, state cards, sheets
// ---------------------------------------------------------------------------
class _FeedbackPage extends StatelessWidget {
const _FeedbackPage();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(
HapSpacing.lg,
60,
HapSpacing.lg,
HapSpacing.xxxl,
),
children: <Widget>[
const H1('Feedback'),
const SizedBox(height: HapSpacing.xxl),
// -- Toast --
const _SectionLabel('Toast'),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Show toast',
icon: FontAwesomeIcons.bell,
onPressed: () {
showHappeningsToast(
context,
title: 'Event published',
description: 'Your event is now live.',
icon: FontAwesomeIcons.solidCircleCheck,
);
},
),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Show error toast',
variant: HappeningsButtonVariant.secondary,
onPressed: () {
showErrorToast(
context,
title: 'Something went wrong',
details: 'Connection timed out. Please try again.',
);
},
),
const SizedBox(height: HapSpacing.xxl),
// -- State cards --
const _SectionLabel('State cards'),
const SizedBox(height: HapSpacing.sm),
HappeningsStateCard(
title: 'Email verification required',
description: 'Check your inbox to verify your email address.',
severity: StateCardSeverity.warning,
actionLabel: 'Resend email',
onAction: () {},
),
const SizedBox(height: HapSpacing.sm),
const HappeningsStateCard(
title: 'Account verified',
severity: StateCardSeverity.success,
),
const SizedBox(height: HapSpacing.sm),
const HappeningsStateCard(
title: 'Payment processing',
description: 'This usually takes a few seconds.',
severity: StateCardSeverity.info,
),
const SizedBox(height: HapSpacing.sm),
const HappeningsStateCard(
title: 'Purchase blocked',
description: 'Your account has been flagged for review.',
severity: StateCardSeverity.error,
),
const SizedBox(height: HapSpacing.xxl),
// -- Sheet --
const _SectionLabel('Sheet'),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Open sheet',
icon: FontAwesomeIcons.upRightFromSquare,
onPressed: () {
showHappeningsSheet<void>(
context: context,
builder: (_) => HappeningsSheetScaffold(
title: 'Confirm action',
subtitle: 'This is a standard Happenings sheet.',
icon: FontAwesomeIcons.circleInfo,
footer: Padding(
padding: const EdgeInsets.symmetric(
horizontal: HapSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
HappeningsButton(
label: 'Confirm',
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(height: HapSpacing.sm),
HappeningsButton(
label: 'Cancel',
variant: HappeningsButtonVariant.ghost,
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
children: const <Widget>[
Body(
'Sheets slide up with scroll physics and a drag handle. '
'The body scrolls independently while the header stays '
'pinned.',
),
SizedBox(height: HapSpacing.lg),
Body(
'Use sheets for confirmations, selections, and actions '
'that need more space than a dialog but less commitment '
'than a full page.',
),
],
),
);
},
),
const SizedBox(height: HapSpacing.xxl),
// -- Markdown --
const _SectionLabel('Markdown'),
const SizedBox(height: HapSpacing.sm),
const HappeningsCard(
child: HappeningsMarkdown(
markdown: '## Markdown rendering\n\n'
'Supports **bold**, *italic*, `inline code`, '
'[links](https://happenings.social), and more.\n\n'
'- List item one\n'
'- List item two\n'
'- List item three\n\n'
'> Blockquotes work too.',
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Overline(label.toUpperCase(), color: ZincColors.zinc500);
}
}