Doc 10 โ Shared Widget Component Spec
Purpose: Spec untuk 12 widget yang dipakai berulang di banyak screen. Flutter developer build widget class ini dulu, lalu semua Page tinggal pakai. Target: DRY, token-driven, accessibility-compliant.
File organization:
lib/shared/widgets/<widget_name>.dartโ satu widget per file.Convention: Setiap widget punya: (1) constructor props, (2) variants/states, (3) accessibility checklist, (4) example usage.
Indexโ
| # | Widget | File | Used in |
|---|---|---|---|
| 1 | ParentBottomNav | parent_bottom_nav.dart | Parent persona (6-tab) |
| 2 | PersonaBottomNav | persona_bottom_nav.dart | Guru / WK / Bendahara / Kepsek / Admin (5-tab) |
| 3 | HeroCard | hero_card.dart | Dashboard hero block (all personas) |
| 4 | QuickActionCard | quick_action_card.dart | Parent Dashboard (8 cards), other dashboards |
| 5 | StatusChip | status_chip.dart | Billing / Attendance / Grades rows |
| 6 | AttendanceStrip | attendance_strip.dart | Parent Dashboard |
| 7 | HariWidget | hari_widget.dart | Parent Dashboard (Today row) |
| 8 | SectionHeader | section_header.dart | All dashboards ("Hari Ini", "Akses Cepat") |
| 9 | PrimaryButton / SecondaryButton | buttons.dart | All forms |
| 10 | AppBarSimple | app_bar_simple.dart | All secondary screens |
| 11 | EmptyState | empty_state.dart | All list pages |
| 12 | GlassAvatar | glass_avatar.dart | Hafalan, WK, Rapor hero |
| 13 | KehadiranWidget | kehadiran_widget.dart | Guru Profil, WK Profil (staff self-attendance summary card) |
| 14 | ClockInButton | clock_in_button.dart | Presensi Hari Ini screen (Guru + WK) |
1. ParentBottomNavโ
Figma reference: 25:99 on Parent Dashboard, 6 tabs (Beranda / Tagihan / Tabungan / Nilai / Absensi / Profil).
class ParentBottomNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const ParentBottomNav({super.key, required this.currentIndex, required this.onTap});
}
States:
activetab: icon colorcs.primary, label colorcs.primary, indicator visibleinactivetabs: icon + label colorcs.onSurfaceVariant, no indicator
Accessibility:
- Each
NavigationDestinationwrapped inSemantics(label: 'Tab: Beranda, 1 of 6')by default - Minimum tap target 48ร48 (NavigationBar enforces)
- Icon + label BOTH visible (never icon-only for cognitive accessibility)
Usage:
Scaffold(
body: child,
bottomNavigationBar: ParentBottomNav(
currentIndex: currentTabIndex,
onTap: (i) => context.go(tabPaths[i]),
),
);
2. PersonaBottomNavโ
Generic 5-tab nav untuk Guru, WK, Bendahara, Kepsek, Admin. Driven by config, not hardcoded per persona.
class PersonaBottomNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
final List<NavItem> items; // exactly 5
const PersonaBottomNav({
super.key,
required this.currentIndex,
required this.onTap,
required this.items,
}) : assert(items.length == 5, 'PersonaBottomNav expects 5 tabs');
}
class NavItem {
final IconData icon;
final String label;
final String route;
const NavItem({required this.icon, required this.label, required this.route});
}
Config per persona (sourced dari Doc 08):
const guruTabs = [
NavItem(icon: Icons.home_rounded, label: 'Beranda', route: '/guru/dashboard'),
NavItem(icon: Icons.qr_code_scanner, label: 'Kehadiran', route: '/guru/attendance'),
NavItem(icon: Icons.grade_rounded, label: 'Nilai', route: '/guru/grades'),
NavItem(icon: Icons.calendar_today_rounded, label: 'Jadwal', route: '/guru/schedule'),
NavItem(icon: Icons.person_rounded, label: 'Profil', route: '/guru/profile'),
];
const adminTabs = [
NavItem(icon: Icons.home_rounded, label: 'Beranda', route: '/admin/dashboard'),
NavItem(icon: Icons.school_rounded, label: 'Akademik', route: '/admin/academic'),
NavItem(icon: Icons.account_balance_wallet_rounded, label: 'Keuangan', route: '/admin/finance'),
NavItem(icon: Icons.group_rounded, label: 'SDM', route: '/admin/hr'),
NavItem(icon: Icons.menu_rounded, label: 'Menu', route: '/admin/menu'),
];
// similar for wali-kelas, bendahara, kepsek
3. HeroCardโ
Figma reference: Top card on every Dashboard, 380ร168, gradient LinearGradient 156ยฐ, 3-stop.
Per-persona gradient mapping (from earlier brand identity work):
| Persona | Stops | Colors |
|---|---|---|
| Parent | [0.0, 0.5, 1.0] | emerald.600 โ emerald.700 โ emerald.800 |
| Guru | [0.0, 0.5, 1.0] | teal.600 โ teal.700 โ teal.800 |
| WK | [0.0, 0.5, 1.0] | blue.600 โ blue.700 โ blue.800 |
| Bendahara | [0.0, 0.5, 1.0] | amber.600 โ amber.700 โ amber.800 |
| Kepsek | [0.0, 0.5, 1.0] | zinc.800 โ zinc.900 โ zinc.950 |
| Admin | [0.0, 0.5, 1.0] | orange.600 โ orange.700 โ orange.800 |
class HeroCard extends StatelessWidget {
final String greeting; // "Assalamu'alaikum"
final String userName;
final String? subtitle; // e.g. "Senin, 7 April 2025 โ 28 Ramadhan 1446 H"
final Widget? trailing; // avatar / notif bell
final List<Color> gradientColors; // 3 stops
final Widget? footer; // optional bottom row (e.g., attendance strip)
const HeroCard({
super.key,
required this.greeting,
required this.userName,
this.subtitle,
this.trailing,
required this.gradientColors,
this.footer,
});
}
Visual spec:
- Size:
width: double.infinity, height: 168(ignorefooterโ usesIntrinsicHeightif footer present) - Padding:
EdgeInsets.all(AppSpacing.n5)(20) - Radius:
AppRadius.lg(16) - Gradient:
LinearGradient(begin: topRight, end: bottomLeft, transform: GradientRotation(156 * pi / 180)) - Text color: always
AppColorsLight.whiteregardless of mode (hero always has dark gradient bg) - Hijri date pill:
BackdropFilterwithImageFilter.blur(sigmaX:12, sigmaY:12), fillColor(0x2DFFFFFF)(white 18%), borderColor(0x4DFFFFFF)(white 30%)
Accessibility:
Semantics(header: true, label: '$greeting $userName, $subtitle')wrapping whole card- Ensure gradient contrast ratio against white text โฅ 4.5:1 (all 6 palettes tested via doc 04)
4. QuickActionCardโ
Figma reference: QA_tagihan (198:38), QA_tabungan (198:46), etc. โ 88ร84 cards in 4-column grid on Parent Dashboard.
class QuickActionCard extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final Color? iconColor; // defaults to cs.primary
final Color? iconBgColor; // defaults to cs.primary.withOpacity(0.12)
const QuickActionCard({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.iconColor,
this.iconBgColor,
});
}
Visual spec:
- Size:
88ร84fixed - Card:
cs.surface, radiusAppRadius.md(12), bordercs.outlineVariant(1px) - Icon container: 40ร40, radius
AppRadius.sm(8), tinted bg (emerald 12% for primary actions) - Label:
AppTypography.caption,cs.onSurface, centered,maxLines: 1,ellipsis - Ripple:
InkWellwithborderRadius: BorderRadius.circular(AppRadius.md)
Parent dashboard mapping (8 cards):
GridView.count(
crossAxisCount: 4,
mainAxisSpacing: AppSpacing.n2,
crossAxisSpacing: AppSpacing.n2,
children: [
QuickActionCard(icon: Icons.receipt_long, label: 'Tagihan', onTap: () => context.go('/parent/billing')),
QuickActionCard(icon: Icons.savings, label: 'Tabungan', onTap: () => context.go('/parent/savings')),
QuickActionCard(icon: Icons.grade, label: 'Nilai', onTap: () => context.go('/parent/grades')),
QuickActionCard(icon: Icons.fact_check, label: 'Absensi', onTap: () => context.go('/parent/attendance')),
QuickActionCard(icon: Icons.description, label: 'Rapor', onTap: () => context.go('/parent/report-card')),
QuickActionCard(icon: Icons.calendar_today, label: 'Jadwal', onTap: () => context.go('/parent/schedule')),
QuickActionCard(icon: Icons.menu_book, label: 'Hafalan', onTap: () => context.go('/parent/hafalan')),
QuickActionCard(icon: Icons.approval, label: 'Izin', onTap: () => context.go('/parent/permissions')),
],
)
5. StatusChipโ
Figma reference: Chip/Lunas (emerald), Chip/Belum Bayar (amber), Chip/Jatuh Tempo (red), attendance chips (Hadir/Izin/Sakit/Alpha/Terlambat).
enum StatusKind {
// Payment
lunas, belumBayar, overdue,
// Attendance
hadir, izin, sakit, alpha, terlambat,
// Generic feedback
success, warning, error, info,
}
class StatusChip extends StatelessWidget {
final StatusKind kind;
final String? customLabel; // override default Indonesian label
const StatusChip({super.key, required this.kind, this.customLabel});
}
Internal mapping:
({String label, Color fg, Color bg}) _spec(BuildContext ctx, StatusKind k) {
final s = StatusColors.of(ctx);
switch (k) {
case StatusKind.lunas: return (label: 'Lunas', fg: s.lunas, bg: s.lunasBg);
case StatusKind.belumBayar: return (label: 'Belum Bayar', fg: s.belumBayar, bg: s.belumBayarBg);
case StatusKind.overdue: return (label: 'Jatuh Tempo', fg: s.overdue, bg: s.overdueBg);
case StatusKind.hadir: return (label: 'Hadir', fg: s.hadir, bg: s.hadirBg);
// ... etc.
}
}
Visual spec:
- Padding:
EdgeInsets.symmetric(horizontal: AppSpacing.n3, vertical: AppSpacing.n1)(12, 4) - Border radius:
AppRadius.full(pill) - Label:
AppTypography.caption.copyWith(color: fg, fontWeight: FontWeight.w600) - No border; bg + fg provides sufficient contrast (verified in doc 04)
Accessibility:
Semantics(label: 'Status: $label')โ explicit status announcement- Don't rely on color alone: the TEXT label always conveys meaning
6. AttendanceStripโ
Figma reference: AttendanceStrip (198:29) on Parent Dashboard, 380ร48.
class AttendanceStrip extends StatelessWidget {
final int hadir; // count
final int izin;
final int sakit;
final int alpha;
final int total;
final VoidCallback? onTap;
const AttendanceStrip({
super.key,
required this.hadir,
required this.izin,
required this.sakit,
required this.alpha,
required this.total,
this.onTap,
});
double get _presenceRatio => total == 0 ? 0 : hadir / total;
}
Visual spec:
- Card with subtle bg
cs.surface, radiusAppRadius.md - Two rows: (a) horizontal stacked progress bar showing hadir/izin/sakit/alpha proportions, (b) text row "X dari Y hari hadir bulan ini"
- Progress bar: use 4
Expandedflex values with respectiveStatusColors
7. HariWidgetโ
Figma reference: HariWidget_jadwal (198:93), HariWidget_hafalan (198:107), HariWidget_chat (198:116) โ "Hari Ini" section on Parent Dashboard.
enum HariVariant { jadwal, hafalan, chat }
class HariWidget extends StatelessWidget {
final HariVariant variant;
final String title; // e.g. "Matematika, 08:00 โ Kelas 7A"
final String? subtitle;
final Widget? trailing;
final VoidCallback onTap;
const HariWidget({
super.key,
required this.variant,
required this.title,
this.subtitle,
this.trailing,
required this.onTap,
});
}
Visual variants:
| Variant | Icon | Accent color | Size |
|---|---|---|---|
jadwal | Icons.calendar_today | cs.primary (emerald) | 380ร86 |
hafalan | Icons.menu_book | cs.secondary (teal) | 380ร68 |
chat | Icons.chat_bubble_outline | cs.tertiary (amber) | 380ร54 |
All: radius AppRadius.md, elevation 0, border cs.outlineVariant.
8. SectionHeaderโ
Figma reference: "Hari Ini", "Akses Cepat" text labels with accent bars.
class SectionHeader extends StatelessWidget {
final String title;
final String? subtitle;
final Widget? trailing; // optional "Lihat Semua" button
const SectionHeader({
super.key,
required this.title,
this.subtitle,
this.trailing,
});
}
Visual spec:
- Padding:
EdgeInsets.symmetric(horizontal: AppSpacing.n4, vertical: AppSpacing.n2) - Title:
AppTypography.h4withfontWeight: FontWeight.w600 - Optional accent bar: 3ร16px rectangle,
cs.primary, at the left of title (matches Figma 198:90) - Subtitle:
AppTypography.bodySmwithcs.onSurfaceVariant, on same line as title if short
9. PrimaryButton / SecondaryButtonโ
Wrappers around Material 3 FilledButton and OutlinedButton that enforce Amal sizing + consistency.
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed; // null = disabled
final IconData? leadingIcon;
final bool isLoading;
final bool fullWidth;
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.leadingIcon,
this.isLoading = false,
this.fullWidth = true,
});
}
class SecondaryButton extends StatelessWidget {
// Same props
}
Visual spec (shared):
- Height: 48 (when
fullWidth), 40 (inline) - Radius:
AppRadius.md(12) - Label:
AppTypography.bodywithfontWeight: FontWeight.w600 - Primary:
FilledButtonwithcs.primarybg - Secondary:
OutlinedButtonwithcs.outlineborder (1.5px) - Loading state: replace label with
CircularProgressIndicator(strokeWidth: 2), keep button size stable - Disabled:
onPressed: nullauto-usescs.surfaceContainerHighestbg +cs.onSurface.withOpacity(0.38)text
10. AppBarSimpleโ
Consistent header for all secondary screens with back button, title, optional trailing action.
class AppBarSimple extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final bool showBack;
const AppBarSimple({
super.key,
required this.title,
this.actions,
this.showBack = true,
});
Size get preferredSize => const Size.fromHeight(56);
}
Visual spec:
- Height: 56
- Background:
cs.surface(appbar theme override) - Back icon:
Icons.arrow_back_ios_new_rounded, 20px - Title:
AppTypography.h3withcs.onSurface - Border bottom: 1px
cs.outlineVariant - Behavior:
showBack && Navigator.canPop(context)โ show back, else hide
Accessibility:
- Back button:
Semantics(label: 'Kembali', button: true)+tooltip: 'Kembali' - Min tap target:
IconButtonis 48ร48 by default โ
11. EmptyStateโ
Figma reference: State - Tagihan Kosong (39:2), State - Nilai Kosong (39:32). Shown when list is empty.
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final String? actionLabel;
final VoidCallback? onAction;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
});
}
Visual spec:
- Centered column, max width 320
- Icon: 64px,
cs.onSurfaceVariant - Title:
AppTypography.h3, centered - Subtitle:
AppTypography.body,cs.onSurfaceVariant, centered, max 2 lines - Action button (if provided):
PrimaryButtonfull-width under text
Accessibility:
- Entire column wrapped in
Semantics(label: '$title. $subtitle')for single TalkBack announcement
12. GlassAvatarโ
Figma reference: Used on Hafalan (32:11), WK (190:24), Rapor (34:10) โ avatars with glass morphism when placed on gradient/image background.
class GlassAvatar extends StatelessWidget {
final String initials; // e.g. "MK" for "M. Khoirul"
final double size; // default 48
final ImageProvider? image; // if null, show initials
const GlassAvatar({
super.key,
required this.initials,
this.size = 48,
this.image,
});
}
Visual spec:
- Circle container,
size ร size - Glass fill:
Color(0x2DFFFFFF)(white 18%) - Glass border: 1px
Color(0x4DFFFFFF)(white 30%) - Backdrop filter:
ImageFilter.blur(sigmaX: 12, sigmaY: 12) - Inner content: either
Image(image)orText(initials, style: AppTypography.caption.copyWith(color: white, fontWeight: bold)) - When not on gradient bg: avoid using this widget โ use standard
CircleAvatarinstead
Implementation note: wrap in ClipOval โ BackdropFilter โ Container(decoration: ...). Backdrop filter requires content behind (otherwise appears as solid rgba white 18%).
13. KehadiranWidgetโ
Figma reference: 392:2 in Guru Profil (289:124) + existing widget in WK Profil (70:318). Summary card menampilkan status kehadiran pegawai hari ini.
enum AttendanceTodayStatus { onTime, late, notYetClockedIn, leave, sick, alpha }
class KehadiranWidget extends StatelessWidget {
final AttendanceTodayStatus status;
final TimeOfDay? clockInTime;
final TimeOfDay? clockOutTime;
final int hadirThisMonth;
final int lateThisMonth;
final int izinThisMonth;
final VoidCallback onTap; // navigates to /{persona}/presensi
const KehadiranWidget({
super.key,
required this.status,
required this.hadirThisMonth,
required this.lateThisMonth,
required this.izinThisMonth,
required this.onTap,
this.clockInTime,
this.clockOutTime,
});
}
Visual spec:
- Size:
width: double.infinity, height: 108 - Card:
cs.surface, radiusAppRadius.md(12), bordercs.outlineVariant - Header: label "Kehadiran Hari Ini" + status pill (On Time / Terlambat / Belum Clock In)
- Two-column value display: Clock In | Clock Out
- Right-side action button: "Clock Out โ" (only when status = onTime/late and clockOutTime == null)
- Bottom summary text:
"Bulan ini: $hadirThisMonth Hadir ยท $lateThisMonth Terlambat ยท $izinThisMonth Izin" - Tap whole card โ
context.go('/{persona}/presensi')
Accessibility:
Semantics(button: true, label: 'Kehadiran hari ini, status: $statusLabel, clock in $clockInTime')
14. ClockInButtonโ
Figma reference: ClockInButton (node in 387:2 + 391:2). Primary CTA untuk rekam kehadiran dengan GPS verification.
enum ClockAction { clockIn, clockOut }
class ClockInButton extends StatelessWidget {
final ClockAction action;
final bool gpsVerified;
final String? gpsLocation; // "MTs Al-Hikmah ยท Dalam radius 50m"
final bool isLoading;
final Future<void> Function() onPressed;
const ClockInButton({
super.key,
required this.action,
required this.gpsVerified,
required this.onPressed,
this.gpsLocation,
this.isLoading = false,
});
}
Visual spec:
- Size:
width: double.infinity, height: 64 - Filled gradient background (emerald 600 โ emerald 800, 156ยฐ) for Clock In; amber gradient for Clock Out
- Large label: "Clock In Sekarang" / "Clock Out Sekarang" (17px, bold, white)
- Sub-text below button:
"Tap untuk rekam kehadiran pukul ${currentTime}"(12px, secondary) - Disabled state when
gpsVerified == false: gray bg + message "Anda berada di luar radius lokasi sekolah" - Loading state: inline
CircularProgressIndicator(strokeWidth: 2, color: white)menggantikan label
Behavior:
- Tap โ show confirmation bottom sheet โ call
onPressedcallback - Backend expected to verify GPS coordinates + return updated attendance record
- On success โ pop back with result + parent screen refreshes
Accessibility:
- Minimum tap target 64 height โ (exceeds 48 minimum)
Semantics(button: true, enabled: gpsVerified && !isLoading, label: '$action button, ${gpsVerified ? "lokasi terverifikasi" : "di luar lokasi"}')
Widget-to-Figma traceabilityโ
Saat developer dapat feedback "widget X di Flutter tidak match Figma", rujuk table berikut untuk buka node di Figma:
| Widget | Figma node(s) | Page |
|---|---|---|
| ParentBottomNav | 25:99 | Parent / Dashboard |
| PersonaBottomNav | 288:55, 291:66, 300:77, 306:52, 336:2 | Each persona dashboard |
| HeroCard (Parent) | 198:13 | Parent / Dashboard |
| QuickActionCard | 198:38, 198:46, 198:52, 198:59, 198:67, 198:72, 198:77, 198:84 | Parent / Dashboard |
| StatusChip | 13:76, 13:78, 13:80 (in Archive, deprecated pattern โ rebuild from scratch per spec) | Foundation |
| AttendanceStrip | 198:29 | Parent / Dashboard |
| HariWidget | 198:93, 198:107, 198:116 | Parent / Dashboard |
| SectionHeader | 198:91, 198:92 (Hari Ini row) | Parent / Dashboard |
| PrimaryButton | Login screen CTAs 9:2 and Payment form buttons | various |
| AppBarSimple | Top bars on secondary screens (e.g., 22:98 back icon on Detail Invoice) | various |
| EmptyState | 39:2, 39:32, 39:62, 39:99 | Parent / states |
| GlassAvatar | 32:11, 190:24, 34:10, 198:28 (Hijri pill uses same alpha pattern) | various |
Widgets NOT in this spec (covered by Flutter standard or platform SDKs)โ
| Need | Use | Reasoning |
|---|---|---|
| Date picker | showDatePicker + DatePickerTheme | Material 3 already styled via AppTheme |
| Time picker | showTimePicker | Same |
| Dialogs / bottom sheets | showModalBottomSheet, AlertDialog | Built-in |
| Snackbar (toast) | ScaffoldMessenger.of(context).showSnackBar | Style via SnackBarTheme in app_theme.dart (extend when needed) |
| Loading overlay | CircularProgressIndicator inside modal | Simple enough not to abstract |
| QR Scanner | mobile_scanner pkg | Used in Guru Kehadiran screen |
| Image pick (bukti transfer) | image_picker pkg | Used in Payment Transfer flow |
| Pull-to-refresh | RefreshIndicator | Built-in |
| Skeleton loader | shimmer pkg | Drop-in for any ListView.builder |
Build order recommendationโ
Saat developer build widget ini, prioritas:
- Week 1:
AppTheme+ generated tokens (already done in Fase A) - Week 2 Day 1-2:
PrimaryButton,SecondaryButton,AppBarSimple,EmptyState,SectionHeaderโ used everywhere, simple - Week 2 Day 3-4:
StatusChip,QuickActionCard,GlassAvatarโ reusable components - Week 2 Day 5:
HeroCard,HariWidget,AttendanceStripโ dashboard-specific - Week 3 Day 1:
ParentBottomNav,PersonaBottomNav+ all 6 shell widgets
Total Week 2โWeek 3: ~7 working days with 1 senior Flutter dev. Setelah ini, Page class build cepat karena widget tinggal compose.
See alsoโ
- Doc 08 (
08-flutter-handoff-map.md) โ which screens use which widgets - Doc 09 (
09-route-specification.md) โ where widgets plug into routes - Doc 04 (
04-accessibility-audit-report.md) โ WCAG rationale for color/sizing choices - Doc 05 (
05-dark-mode-pairing-guide.md) โ dark mode color pairs for each widget flutter-starter/lib/core/theme/app_theme.dartโ token source