Lewati ke konten utama

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โ€‹

#WidgetFileUsed in
1ParentBottomNavparent_bottom_nav.dartParent persona (6-tab)
2PersonaBottomNavpersona_bottom_nav.dartGuru / WK / Bendahara / Kepsek / Admin (5-tab)
3HeroCardhero_card.dartDashboard hero block (all personas)
4QuickActionCardquick_action_card.dartParent Dashboard (8 cards), other dashboards
5StatusChipstatus_chip.dartBilling / Attendance / Grades rows
6AttendanceStripattendance_strip.dartParent Dashboard
7HariWidgethari_widget.dartParent Dashboard (Today row)
8SectionHeadersection_header.dartAll dashboards ("Hari Ini", "Akses Cepat")
9PrimaryButton / SecondaryButtonbuttons.dartAll forms
10AppBarSimpleapp_bar_simple.dartAll secondary screens
11EmptyStateempty_state.dartAll list pages
12GlassAvatarglass_avatar.dartHafalan, WK, Rapor hero
13KehadiranWidgetkehadiran_widget.dartGuru Profil, WK Profil (staff self-attendance summary card)
14ClockInButtonclock_in_button.dartPresensi 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:

  • active tab: icon color cs.primary, label color cs.primary, indicator visible
  • inactive tabs: icon + label color cs.onSurfaceVariant, no indicator

Accessibility:

  • Each NavigationDestination wrapped in Semantics(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):

PersonaStopsColors
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 (ignore footer โ€” uses IntrinsicHeight if 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.white regardless of mode (hero always has dark gradient bg)
  • Hijri date pill: BackdropFilter with ImageFilter.blur(sigmaX:12, sigmaY:12), fill Color(0x2DFFFFFF) (white 18%), border Color(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ร—84 fixed
  • Card: cs.surface, radius AppRadius.md (12), border cs.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: InkWell with borderRadius: 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, radius AppRadius.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 Expanded flex values with respective StatusColors

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:

VariantIconAccent colorSize
jadwalIcons.calendar_todaycs.primary (emerald)380ร—86
hafalanIcons.menu_bookcs.secondary (teal)380ร—68
chatIcons.chat_bubble_outlinecs.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.h4 with fontWeight: FontWeight.w600
  • Optional accent bar: 3ร—16px rectangle, cs.primary, at the left of title (matches Figma 198:90)
  • Subtitle: AppTypography.bodySm with cs.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.body with fontWeight: FontWeight.w600
  • Primary: FilledButton with cs.primary bg
  • Secondary: OutlinedButton with cs.outline border (1.5px)
  • Loading state: replace label with CircularProgressIndicator(strokeWidth: 2), keep button size stable
  • Disabled: onPressed: null auto-uses cs.surfaceContainerHighest bg + 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.h3 with cs.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: IconButton is 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): PrimaryButton full-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) or Text(initials, style: AppTypography.caption.copyWith(color: white, fontWeight: bold))
  • When not on gradient bg: avoid using this widget โ€” use standard CircleAvatar instead

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, radius AppRadius.md (12), border cs.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 onPressed callback
  • 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:

WidgetFigma node(s)Page
ParentBottomNav25:99Parent / Dashboard
PersonaBottomNav288:55, 291:66, 300:77, 306:52, 336:2Each persona dashboard
HeroCard (Parent)198:13Parent / Dashboard
QuickActionCard198:38, 198:46, 198:52, 198:59, 198:67, 198:72, 198:77, 198:84Parent / Dashboard
StatusChip13:76, 13:78, 13:80 (in Archive, deprecated pattern โ€” rebuild from scratch per spec)Foundation
AttendanceStrip198:29Parent / Dashboard
HariWidget198:93, 198:107, 198:116Parent / Dashboard
SectionHeader198:91, 198:92 (Hari Ini row)Parent / Dashboard
PrimaryButtonLogin screen CTAs 9:2 and Payment form buttonsvarious
AppBarSimpleTop bars on secondary screens (e.g., 22:98 back icon on Detail Invoice)various
EmptyState39:2, 39:32, 39:62, 39:99Parent / states
GlassAvatar32: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)โ€‹

NeedUseReasoning
Date pickershowDatePicker + DatePickerThemeMaterial 3 already styled via AppTheme
Time pickershowTimePickerSame
Dialogs / bottom sheetsshowModalBottomSheet, AlertDialogBuilt-in
Snackbar (toast)ScaffoldMessenger.of(context).showSnackBarStyle via SnackBarTheme in app_theme.dart (extend when needed)
Loading overlayCircularProgressIndicator inside modalSimple enough not to abstract
QR Scannermobile_scanner pkgUsed in Guru Kehadiran screen
Image pick (bukti transfer)image_picker pkgUsed in Payment Transfer flow
Pull-to-refreshRefreshIndicatorBuilt-in
Skeleton loadershimmer pkgDrop-in for any ListView.builder

Build order recommendationโ€‹

Saat developer build widget ini, prioritas:

  1. Week 1: AppTheme + generated tokens (already done in Fase A)
  2. Week 2 Day 1-2: PrimaryButton, SecondaryButton, AppBarSimple, EmptyState, SectionHeader โ€” used everywhere, simple
  3. Week 2 Day 3-4: StatusChip, QuickActionCard, GlassAvatar โ€” reusable components
  4. Week 2 Day 5: HeroCard, HariWidget, AttendanceStrip โ€” dashboard-specific
  5. 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