<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aris Candra Muzaffar</title>
    <description>The latest articles on DEV Community by Aris Candra Muzaffar (@ariscandra).</description>
    <link>https://dev.to/ariscandra</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3877427%2Fb8a43761-8567-400e-8a5b-e4b0dada08c0.jpeg</url>
      <title>DEV Community: Aris Candra Muzaffar</title>
      <link>https://dev.to/ariscandra</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ariscandra"/>
    <language>en</language>
    <item>
      <title>Panduan Pengujian: Implementasi Widget, Properti, dan Arsitektur SaryPOS</title>
      <dc:creator>Aris Candra Muzaffar</dc:creator>
      <pubDate>Mon, 13 Apr 2026 21:26:01 +0000</pubDate>
      <link>https://dev.to/ariscandra/panduan-pengujian-implementasi-widget-properti-dan-arsitektur-sarypos-hp1</link>
      <guid>https://dev.to/ariscandra/panduan-pengujian-implementasi-widget-properti-dan-arsitektur-sarypos-hp1</guid>
      <description>&lt;p&gt;Dokumen ini merangkum hasil audit codebase aplikasi &lt;strong&gt;SaryPOS&lt;/strong&gt; (folder &lt;code&gt;sarypos/&lt;/code&gt;) untuk persiapan demonstrasi dan tanya jawab dengan dosen. Setiap bagian menyertakan &lt;strong&gt;lokasi berkas&lt;/strong&gt; dan &lt;strong&gt;cuplikan kode&lt;/strong&gt; yang dapat Anda tunjukkan langsung di IDE.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Konteks proyek:&lt;/strong&gt; aplikasi POS (Point of Sale) ritel untuk Sary Mart, dibuat dengan &lt;strong&gt;Flutter&lt;/strong&gt; dan backend &lt;strong&gt;Supabase&lt;/strong&gt; (PostgreSQL + Storage), dengan pemisahan peran &lt;strong&gt;owner&lt;/strong&gt; vs &lt;strong&gt;karyawan/kasir&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Ringkasan arsitektur (kalimat untuk pembuka presentasi)
&lt;/h2&gt;

&lt;p&gt;SaryPOS memuat konfigurasi dan font di &lt;code&gt;main&lt;/code&gt;, menginisialisasi Supabase, lalu menjalankan &lt;code&gt;GetMaterialApp&lt;/code&gt; dengan &lt;strong&gt;tumpukan warisan (InheritedNotifier)&lt;/strong&gt; untuk tema, sesi pengguna, dan notifikasi in-app. &lt;strong&gt;State utama&lt;/strong&gt; dikelola lewat &lt;code&gt;ChangeNotifier&lt;/code&gt; (&lt;code&gt;PengaturSesi&lt;/code&gt;, &lt;code&gt;PengaturTema&lt;/code&gt;, &lt;code&gt;PengelolaNotifikasiInApp&lt;/code&gt;) dan diwariskan ke seluruh pohon widget. &lt;strong&gt;Navigasi tab&lt;/strong&gt; ada di &lt;code&gt;HalamanUtamaDenganNav&lt;/code&gt; (&lt;code&gt;IndexedStack&lt;/code&gt;); &lt;strong&gt;halaman detail&lt;/strong&gt; banyak memakai &lt;code&gt;Navigator.push&lt;/code&gt; + &lt;code&gt;MaterialPageRoute&lt;/code&gt;, sementara &lt;strong&gt;guard owner&lt;/strong&gt; memakai &lt;strong&gt;GetX&lt;/strong&gt; (&lt;code&gt;Get.to&lt;/code&gt;, &lt;code&gt;Get.dialog&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Stack teknologi dan dependensi utama
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Paket / pilihan&lt;/th&gt;
&lt;th&gt;Berkas acuan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Flutter (SDK Dart &lt;code&gt;^3.11.1&lt;/code&gt; di &lt;code&gt;pubspec.yaml&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sarypos/pubspec.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend klien&lt;/td&gt;
&lt;td&gt;&lt;code&gt;supabase_flutter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lib/data/sources/supabase_klien.dart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navigasi/GetX&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get&lt;/code&gt; — terutama &lt;code&gt;GetMaterialApp&lt;/code&gt;, &lt;code&gt;Get.to&lt;/code&gt;, &lt;code&gt;Get.dialog&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lib/main.dart&lt;/code&gt;, &lt;code&gt;lib/core/penjaga_rute_owner.dart&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Konfigurasi rahasia&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;flutter_dotenv&lt;/code&gt; + &lt;code&gt;--dart-define&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lib/config/supabase_konfigurasi.dart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preferensi lokal&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shared_preferences&lt;/code&gt; (tema, “lanjut kasir tanpa owner”)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lib/core/pengatur_tema.dart&lt;/code&gt;, &lt;code&gt;lib/core/pengatur_sesi.dart&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google_fonts&lt;/code&gt;, &lt;code&gt;intl&lt;/code&gt;, &lt;code&gt;flutter_typeahead&lt;/code&gt;, &lt;code&gt;image_picker&lt;/code&gt;, &lt;code&gt;file_picker&lt;/code&gt;, &lt;code&gt;barcode&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pubspec.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ekspor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pdf&lt;/code&gt;, &lt;code&gt;printing&lt;/code&gt;, &lt;code&gt;share_plus&lt;/code&gt;, &lt;code&gt;csv&lt;/code&gt;, &lt;code&gt;path_provider&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lib/core/ekspor/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;State management yang dipakai di kode:&lt;/strong&gt; tidak ada Provider/Bloc/Riverpod; yang dominan adalah &lt;strong&gt;ChangeNotifier + ListenableBuilder/InheritedNotifier&lt;/strong&gt; dan &lt;strong&gt;GetX sebagian&lt;/strong&gt; untuk dialog/rute owner.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Titik masuk aplikasi dan pemilihan “home”
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/main.dart&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Alur singkat:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;WidgetsFlutterBinding.ensureInitialized()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Muat &lt;code&gt;.env&lt;/code&gt; (bukan web; error ditelan jika berkas tidak ada)&lt;/li&gt;
&lt;li&gt;Preload font Plus Jakarta Sans&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inisialisasiSupabase()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PengaturTema.muatAwal()&lt;/code&gt; lalu &lt;code&gt;runApp(MainApp(...))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MainApp&lt;/code&gt; membungkus anak dengan &lt;code&gt;WarisanTema&lt;/code&gt; → &lt;code&gt;WarisanSesi&lt;/code&gt; → &lt;code&gt;WarisanNotifikasiInApp&lt;/code&gt; → &lt;code&gt;GetMaterialApp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;home&lt;/code&gt; ditentukan oleh &lt;code&gt;_pilihHome()&lt;/code&gt;: loading → error koneksi → pembuka owner pertama → &lt;code&gt;HalamanUtamaDenganNav&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Future&amp;lt;void&amp;gt; main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kIsWeb) {
    try {
      await dotenv.load(fileName: '.env');
    } catch (_) {}
  }
  try {
    await GoogleFonts.pendingFonts([GoogleFonts.plusJakartaSans()]);
  } catch (_) {}

  await inisialisasiSupabase();

  final pengaturTema = PengaturTema();
  await pengaturTema.muatAwal();

  runApp(MainApp(pengaturTema: pengaturTema));
}
// ...
              child: GetMaterialApp(
                title: 'SaryPOS',
                theme: temaSaryposTerang(),
                darkTheme: temaSaryposGelap(),
                themeMode: widget.pengaturTema.modeMaterial,
                home: _pilihHome(),
                builder: (context, child) {
                  return Stack(
                    fit: StackFit.expand,
                    children: [
                      child ?? const SizedBox.shrink(),
                      const Positioned(
                        top: 0,
                        left: 0,
                        right: 0,
                        child: SafeArea(
                          bottom: false,
                          child: Padding(
                            padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
                            child: BannerNotifikasiInApp(),
                          ),
                        ),
                      ),
                    ],
                  );
                },
              ),
// ...
  Widget _pilihHome() {
    if (_sesi.sedangMemeriksaSesi) {
      return const Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CircularProgressIndicator(),
              SizedBox(height: 16),
              Text('Menyiapkan SaryPOS…'),
            ],
          ),
        ),
      );
    }
    if (_sesi.sedangMengalamiError) {
      return HalamanErrorKoneksi(
        pesan: _sesi.pesanErrorSesi ?? 'Gagal menyiapkan SaryPOS.',
        cobaLagi: _sesi.muatUlangSesi,
      );
    }
    if (_sesi.perluHalamanPembukaOwner) {
      return HalamanPembukaOwnerPertama(pengatur: _sesi);
    }
    return const HalamanUtamaDenganNav();
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Properti / perilaku penting untuk ditanya dosen:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tidak ada &lt;code&gt;routes&lt;/code&gt;/&lt;code&gt;onGenerateRoute&lt;/code&gt;&lt;/strong&gt; — satu &lt;code&gt;home&lt;/code&gt; dinamis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Banner notifikasi&lt;/strong&gt; selalu di atas konten lewat &lt;code&gt;Stack&lt;/code&gt; di &lt;code&gt;builder&lt;/code&gt; &lt;code&gt;GetMaterialApp&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. Konfigurasi Supabase (URL, anon key, service role)
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/config/supabase_konfigurasi.dart&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SUPABASE_URL&lt;/code&gt; dan &lt;code&gt;SUPABASE_ANON_KEY&lt;/code&gt; &lt;strong&gt;wajib&lt;/strong&gt; (dari &lt;code&gt;String.fromEnvironment&lt;/code&gt; atau &lt;code&gt;.env&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; &lt;strong&gt;opsional&lt;/strong&gt;; jika ada, dipakai untuk operasi admin tertentu (mis. form karyawan) — &lt;strong&gt;ini topik sensitif keamanan&lt;/strong&gt; untuk diskusi dengan dosen (anon key untuk klien umum; service role sebaiknya tidak tersebar di aplikasi produksi).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;String _ambilKonfigurasiWajib({
  required String namaKunci,
  required String nilaiDartDefine,
}) {
  final nilaiDefine = nilaiDartDefine.trim();
  if (nilaiDefine.isNotEmpty) {
    return nilaiDefine;
  }

  final nilaiEnv = (dotenv.env[namaKunci] ?? '').trim();
  if (nilaiEnv.isNotEmpty) {
    return nilaiEnv;
  }

  throw StateError(
    'Konfigurasi `$namaKunci` belum tersedia. '
    'Isi di .env atau kirim lewat --dart-define.',
  );
}
// ...
String? get supabaseServiceRoleKey {
  final nilaiDefine = _supabaseServiceRoleDartDefine.trim();
  if (nilaiDefine.isNotEmpty) {
    return nilaiDefine;
  }
  final nilaiEnv = (dotenv.env['SUPABASE_SERVICE_ROLE_KEY'] ?? '').trim();
  if (nilaiEnv.isNotEmpty) {
    return nilaiEnv;
  }
  return null;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Sesi pengguna (owner, kasir tanpa owner, retry)
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/pengatur_sesi.dart&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getter penting untuk logika UI:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;perluHalamanPembukaOwner&lt;/code&gt; — true jika belum ada owner aktif, belum login, dan pengguna belum memilih “lanjut kasir tanpa owner”.&lt;/li&gt;
&lt;li&gt;Retry &lt;strong&gt;3 kali&lt;/strong&gt;, timeout &lt;strong&gt;5 detik&lt;/strong&gt; per percobaan (konstanta di kelas).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PengaturSesi extends ChangeNotifier {
  PengaturSesi() {
    _inisialisasi();
  }

  static const int _maksPercobaanMuatSesi = 3;
  static const Duration _timeoutPercobaanMuatSesi = Duration(seconds: 5);

  static const _kunciPrefLanjutKasir = 'sarypos_lanjut_tanpa_owner';
// ...
  bool get perluHalamanPembukaOwner =&amp;gt;
      _adaOwnerAktif == false &amp;amp;&amp;amp; _pengguna == null &amp;amp;&amp;amp; !_lanjutKasirTanpaOwner;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. Warisan state: &lt;code&gt;InheritedNotifier&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  6.1 &lt;code&gt;WarisanSesi&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/warisan_sesi.dart&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class WarisanSesi extends InheritedNotifier&amp;lt;PengaturSesi&amp;gt; {
  const WarisanSesi({
    super.key,
    required PengaturSesi pengatur,
    required super.child,
  }) : super(notifier: pengatur);

  static PengaturSesi dari(BuildContext context) {
    final w = context.dependOnInheritedWidgetOfExactType&amp;lt;WarisanSesi&amp;gt;();
    assert(w != null, 'WarisanSesi tidak ditemukan di pohon widget');
    return w!.notifier!;
  }

  static PengaturSesi? mungkinDari(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType&amp;lt;WarisanSesi&amp;gt;()?.notifier;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Jawaban singkat ujian:&lt;/strong&gt; &lt;code&gt;dependOnInheritedWidgetOfExactType&lt;/code&gt; membuat widget &lt;strong&gt;rebuild&lt;/strong&gt; saat notifier (&lt;code&gt;PengaturSesi&lt;/code&gt;) memanggil &lt;code&gt;notifyListeners&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 &lt;code&gt;WarisanTema&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/warisan_tema.dart&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class WarisanTema extends InheritedNotifier&amp;lt;PengaturTema&amp;gt; {
  const WarisanTema({
    super.key,
    required PengaturTema pengatur,
    required super.child,
  }) : super(notifier: pengatur);

  static PengaturTema dari(BuildContext context) {
    final w = context.dependOnInheritedWidgetOfExactType&amp;lt;WarisanTema&amp;gt;();
    assert(w != null, 'WarisanTema tidak ditemukan di pohon widget');
    return w!.notifier!;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Penjaga akses: login dan owner
&lt;/h2&gt;

&lt;h3&gt;
  
  
  7.1 Cegah aksi jika belum login
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/penjaga_aksi_masuk.dart&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bool cegahJikaBelumLogin(
  BuildContext context, {
  String? pesan,
}) {
  final s = WarisanSesi.dari(context);
  if (s.sedangMemeriksaSesi) {
    return true;
  }
  if (s.pengguna != null) {
    return false;
  }
  tampilkanSnackbarSarypos(
    context,
    tipe: TipeSnackbarSarypos.info,
    pesan:
        pesan ??
        'Masuk ke akun terlebih dahulu untuk mengakses fitur ini.',
  );
  return true;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Properti perilaku:&lt;/strong&gt; mengembalikan &lt;code&gt;true&lt;/code&gt; artinya &lt;strong&gt;aksi harus dibatalkan&lt;/strong&gt; (caller biasanya &lt;code&gt;if (cegahJikaBelumLogin(context)) return;&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  7.2 Hanya owner yang boleh membuka halaman tertentu
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/penjaga_rute_owner.dart&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Future&amp;lt;T?&amp;gt; dorongJikaOwner&amp;lt;T extends Object?&amp;gt;(
  BuildContext context,
  WidgetBuilder builder, {
  String pesanDitolak =
      'Hanya pemilik toko (owner) yang dapat membuka halaman ini.',
}) async {
  final sesi = WarisanSesi.dari(context);
  final p = sesi.pengguna;
  if (p == null || !p.isOwner) {
    if (!context.mounted) {
      return null;
    }
    await Get.dialog&amp;lt;void&amp;gt;(
      AlertDialog(
        title: const Text('Akses Ditolak'),
        content: Text(pesanDitolak),
        actions: [
          TextButton(
            onPressed: () =&amp;gt; Get.back&amp;lt;void&amp;gt;(),
            child: const Text('Oke'),
          ),
        ],
      ),
    );
    return null;
  }

  if (!context.mounted) {
    return null;
  }
  return Get.to&amp;lt;T&amp;gt;(() =&amp;gt; builder(context));
}

bool penggunaAdalahOwner(BuildContext context) {
  return WarisanSesi.dari(context).pengguna?.isOwner ?? false;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Catatan ujian:&lt;/strong&gt; pengecekan owner memakai &lt;code&gt;PenggunaModel.isOwner&lt;/code&gt; (&lt;code&gt;peran == 'owner'&lt;/code&gt; di model).&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Shell navigasi utama (tab Beranda / Kasir / Saya)
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/features/dashboard/halaman_utama_dengan_nav.dart&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IndexedStack&lt;/code&gt; menjaga &lt;strong&gt;state tiap tab&lt;/strong&gt; tetap hidup saat pindah tab.&lt;/li&gt;
&lt;li&gt;AppBar menampilkan ikon log aktivitas &lt;strong&gt;hanya&lt;/strong&gt; jika tab Beranda &lt;strong&gt;dan&lt;/strong&gt; pengguna owner.&lt;/li&gt;
&lt;li&gt;FAB tengah mengarah ke tab Kasir.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  @override
  Widget build(BuildContext context) {
    final isKasirAktif = _indeksSaatIni == 1;
    final pemilikBeranda =
        _indeksSaatIni == 0 &amp;amp;&amp;amp;
        (WarisanSesi.dari(context).pengguna?.isOwner ?? false);
    return Scaffold(
      extendBody: true,
      appBar: AppBarSarypos(
        judul: _judulTab[_indeksSaatIni],
        aksi: pemilikBeranda
            ? [
                IconButton(
                  onPressed: () async {
                    await dorongJikaOwner(
                      context,
                      (_) =&amp;gt; const HalamanLogAktivitas(),
                    );
                  },
                  icon: const Icon(Icons.notifications_none_outlined),
                  tooltip: 'Log Aktivitas',
                ),
              ]
            : null,
      ),
      body: IndexedStack(
        index: _indeksSaatIni,
        children: [
          HalamanDashboard(key: _kunciDashboard),
          const HalamanKasirMenu(),
          const HalamanUserPengaturan(),
        ],
      ),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  9. Tema dan palet warna merek
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/config/theme/sarypos_theme.dart&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kelas &lt;code&gt;WarnaSarypos&lt;/code&gt;&lt;/strong&gt; memusatkan warna merek (merah Sary, teal, emas, abu hangat, hijau sukses). Fungsi &lt;code&gt;temaSaryposTerang()&lt;/code&gt; / &lt;code&gt;temaSaryposGelap()&lt;/code&gt; membangun &lt;code&gt;ThemeData&lt;/code&gt; Material 3 dengan teks &lt;strong&gt;Plus Jakarta Sans&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class WarnaSarypos {
  static const Color saryRed = Color(0xFFD3291E);
  static const Color deepTeal = Color(0xFF1C4546);
  static const Color saryGold = Color(0xFFE4AF1A);
  static const Color cleanWhite = Color(0xFFFEFEFE);
  static const Color warmGray = Color(0xFFCDC5BB);
  static const Color darkStone = Color(0xFF6E6B61);

  static const Color hijauSukses = Color(0xFF1E8E3E);
// ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Transisi halaman:&lt;/strong&gt; &lt;code&gt;PageTransitionsTheme&lt;/code&gt; memakai &lt;code&gt;CupertinoPageTransitionsBuilder&lt;/code&gt; untuk Android/iOS/macOS (geser seperti iOS).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const PageTransitionsTheme _transisiHalamanStandar = PageTransitionsTheme(
  builders: &amp;lt;TargetPlatform, PageTransitionsBuilder&amp;gt;{
    TargetPlatform.android: CupertinoPageTransitionsBuilder(),
    TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.fuchsia: FadeUpwardsPageTransitionsBuilder(),
  },
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  10. Widget reusable di &lt;code&gt;lib/widgets/&lt;/code&gt; — properti dan implementasi
&lt;/h2&gt;

&lt;h3&gt;
  
  
  10.1 &lt;code&gt;CardSarypos&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/card_sarypos.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Jenis:&lt;/strong&gt; &lt;code&gt;StatelessWidget&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Properti konstruktor:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Properti&lt;/th&gt;
&lt;th&gt;Tipe&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Fungsi&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;child&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Widget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wajib&lt;/td&gt;
&lt;td&gt;Isi kartu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;margin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EdgeInsetsGeometry?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;Margin &lt;code&gt;Card&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;borderRadius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BorderRadius?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 px all&lt;/td&gt;
&lt;td&gt;Sudut membulat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;elevation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;double?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dari &lt;code&gt;cardTheme&lt;/code&gt; atau 2&lt;/td&gt;
&lt;td&gt;Bayangan; jika ≤0, “datar”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;onTap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VoidCallback?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;Jika ada, dibungkus &lt;code&gt;InkWell&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tampilkanKonturTipis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Garis tipis saat elevasi datar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class CardSarypos extends StatelessWidget {
  const CardSarypos({
    super.key,
    required this.child,
    this.margin,
    this.borderRadius,
    this.elevation,
    this.onTap,
    this.tampilkanKonturTipis = false,
  });

  final Widget child;
  final EdgeInsetsGeometry? margin;
  final BorderRadius? borderRadius;

  final double? elevation;
  final VoidCallback? onTap;

  final bool tampilkanKonturTipis;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Ide desain:&lt;/strong&gt; gradien latar dari &lt;code&gt;cleanWhite&lt;/code&gt; / &lt;code&gt;surfaceContainerHighest&lt;/code&gt; ke aksen teal transparan, dengan “blob” dekoratif emas/merah sangat halus.&lt;/p&gt;
&lt;h3&gt;
  
  
  10.2 &lt;code&gt;CardRingkasan&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/card_ringkasan.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Membungkus konten dengan &lt;code&gt;CardSarypos(elevation: 0)&lt;/code&gt; dan layout baris: teks kiri, ikon bundar kanan.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Properti&lt;/th&gt;
&lt;th&gt;Tipe&lt;/th&gt;
&lt;th&gt;Keterangan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;judul&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Label ringkas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nilaiUtama&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Angka/teks besar (mis. format rupiah)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ikon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IconData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ikon ringkasan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;warnaAksen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Color?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opsional; default teal (terang) / secondary (gelap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;margin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EdgeInsetsGeometry?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default &lt;code&gt;EdgeInsets.zero&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class CardRingkasan extends StatelessWidget {
  const CardRingkasan({
    super.key,
    required this.judul,
    required this.nilaiUtama,
    required this.ikon,
    this.warnaAksen,
    this.margin,
  });

  final String judul;
  final String nilaiUtama;
  final IconData ikon;
  final Color? warnaAksen;
  final EdgeInsetsGeometry? margin;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  10.3 &lt;code&gt;AppBarSarypos&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/appbar_sarypos.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Mengimplementasikan &lt;code&gt;PreferredSizeWidget&lt;/code&gt; (&lt;code&gt;preferredSize&lt;/code&gt; = tinggi toolbar standar).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Properti&lt;/th&gt;
&lt;th&gt;Tipe&lt;/th&gt;
&lt;th&gt;Keterangan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;judul&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Judul utama AppBar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aksi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;List&amp;lt;Widget&amp;gt;?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Aksi kustom; digabung dengan tombol siklus tema&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Perilaku tema:&lt;/strong&gt; membaca &lt;code&gt;WarisanTema.dari(context)&lt;/code&gt;, siklus &lt;code&gt;ikutiSistem&lt;/code&gt; → &lt;code&gt;terang&lt;/code&gt; → &lt;code&gt;gelap&lt;/code&gt; lewat &lt;code&gt;pengaturTema.atur(...)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class AppBarSarypos extends StatelessWidget implements PreferredSizeWidget {
  const AppBarSarypos({super.key, required this.judul, this.aksi});

  final String judul;
  final List&amp;lt;Widget&amp;gt;? aksi;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  10.4 &lt;code&gt;EmptyStateGenerik&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/empty_state_generik.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Memakai &lt;code&gt;LayoutBuilder&lt;/code&gt; untuk menyesuaikan padding dan ukuran ikon jika ruang vertikal sangat kecil (&lt;code&gt;maxHeight &amp;lt; 90&lt;/code&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Properti&lt;/th&gt;
&lt;th&gt;Tipe&lt;/th&gt;
&lt;th&gt;Keterangan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ikon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IconData?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opsional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;judul&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opsional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pesan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wajib — pesan utama&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;labelTombol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Perlu dipasangkan dengan &lt;code&gt;onTekanTombol&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;onTekanTombol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VoidCallback?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Menampilkan &lt;code&gt;ElevatedButton&lt;/code&gt; jika keduanya non-null&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class EmptyStateGenerik extends StatelessWidget {
  const EmptyStateGenerik({
    super.key,
    this.ikon,
    this.judul,
    required this.pesan,
    this.labelTombol,
    this.onTekanTombol,
  });

  final IconData? ikon;
  final String? judul;
  final String pesan;
  final String? labelTombol;
  final VoidCallback? onTekanTombol;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  10.5 &lt;code&gt;tampilkanSnackbarSarypos&lt;/code&gt; dan &lt;code&gt;TipeSnackbarSarypos&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/snackbar_sarypos.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Bukan widget, tetapi &lt;strong&gt;API imperatif&lt;/strong&gt; untuk feedback konsisten.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enum: &lt;code&gt;sukses&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;, &lt;code&gt;peringatan&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Memanggil &lt;code&gt;ScaffoldMessenger.of(context).clearSnackBars()&lt;/code&gt; lalu &lt;code&gt;showSnackBar&lt;/code&gt; dengan konten gradien custom (bukan warna flat default)
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enum TipeSnackbarSarypos { sukses, error, info, peringatan }

void tampilkanSnackbarSarypos(
  BuildContext context, {
  required TipeSnackbarSarypos tipe,
  required String pesan,
}) {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  10.6 Skeleton loading (&lt;code&gt;SkeletonBox&lt;/code&gt;, &lt;code&gt;SkeletonLine&lt;/code&gt;, &lt;code&gt;SkeletonCircle&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/skeleton_sarypos.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;SkeletonBox&lt;/code&gt; memakai &lt;code&gt;_ShimmerSkeletonCore&lt;/code&gt; (&lt;code&gt;AnimationController.repeat&lt;/code&gt;) untuk gelombang sorot.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Widget&lt;/th&gt;
&lt;th&gt;Properti utama&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SkeletonBox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;borderRadius&lt;/code&gt; (default 12), &lt;code&gt;baseColor?&lt;/code&gt;, &lt;code&gt;highlightColor?&lt;/code&gt;, &lt;code&gt;duration&lt;/code&gt; (default 1300 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SkeletonLine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt; (12), &lt;code&gt;borderRadius&lt;/code&gt; (999)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SkeletonCircle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;diameter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class SkeletonBox extends StatelessWidget {
  const SkeletonBox({
    super.key,
    required this.width,
    required this.height,
    this.borderRadius = 12,
    this.baseColor,
    this.highlightColor,
    this.duration = const Duration(milliseconds: 1300),
  });

  final double width;
  final double height;
  final double borderRadius;
  final Color? baseColor;
  final Color? highlightColor;
  final Duration duration;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  10.7 &lt;code&gt;BannerNotifikasiInApp&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/banner_notifikasi_in_app.dart&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Tanpa properti publik; membaca &lt;code&gt;WarisanNotifikasiInApp.mungkinDari(context)&lt;/code&gt; dan &lt;code&gt;ListenableBuilder&lt;/code&gt; pada pengelola. Tap menutup lewat &lt;code&gt;pengelola.tutup()&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  10.8 &lt;code&gt;PengelolaNotifikasiInApp&lt;/code&gt; (inti logika banner)
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/core/pengelola_notifikasi_in_app.dart&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tampilkan(tipe, pesan, durasi)&lt;/code&gt; — deduplikasi pesan identik dalam &lt;strong&gt;2 detik&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Timer otomatis menghapus notifikasi setelah &lt;code&gt;durasi&lt;/code&gt; (default 4 detik)
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PengelolaNotifikasiInApp extends ChangeNotifier {
  ItemNotifikasiInApp? _aktif;
  Timer? _timer;
  String? _pesanTerakhir;
  DateTime? _waktuPesanTerakhir;

  ItemNotifikasiInApp? get aktif =&amp;gt; _aktif;

  void tampilkan({
    required TipeNotifikasiInApp tipe,
    required String pesan,
    Duration durasi = const Duration(seconds: 4),
  }) {
    final sekarang = DateTime.now();
    if (_pesanTerakhir == pesan &amp;amp;&amp;amp;
        _waktuPesanTerakhir != null &amp;amp;&amp;amp;
        sekarang.difference(_waktuPesanTerakhir!) &amp;lt;
            const Duration(seconds: 2)) {
      return;
    }
    _pesanTerakhir = pesan;
    _waktuPesanTerakhir = sekarang;

    _timer?.cancel();
    _aktif = ItemNotifikasiInApp(
      id: '${sekarang.microsecondsSinceEpoch}',
      tipe: tipe,
      pesan: pesan,
    );
    notifyListeners();

    _timer = Timer(durasi, () {
      _aktif = null;
      notifyListeners();
    });
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  10.9 &lt;code&gt;HalamanErrorKoneksi&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/widgets/halaman_error_koneksi.dart&lt;/code&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Properti&lt;/th&gt;
&lt;th&gt;Tipe&lt;/th&gt;
&lt;th&gt;Keterangan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pesan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Detail error untuk pengguna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cobaLagi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Future&amp;lt;void&amp;gt; Function()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Async callback tombol “Coba Lagi”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class HalamanErrorKoneksi extends StatelessWidget {
  const HalamanErrorKoneksi({
    super.key,
    required this.pesan,
    required this.cobaLagi,
  });

  final String pesan;
  final Future&amp;lt;void&amp;gt; Function() cobaLagi;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. Lapisan data: contoh model dan alur transaksi
&lt;/h2&gt;
&lt;h3&gt;
  
  
  11.1 Model pengguna dan peran owner
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/data/models/pengguna_model.dart&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PenggunaModel {
  const PenggunaModel({
    required this.id,
    required this.idAuth,
    required this.namaLengkap,
    required this.email,
    required this.peran,
    required this.aktif,
    this.sandiLogin,
  });

  final String id;
  final String idAuth;
  final String namaLengkap;
  final String email;
  final String peran;
  final bool aktif;
  final String? sandiLogin;

  bool get isOwner =&amp;gt; peran == 'owner';

  factory PenggunaModel.dariBaris(Map&amp;lt;String, dynamic&amp;gt; row) {
    return PenggunaModel(
      id: row['id'].toString(),
      idAuth: row['id_auth'].toString(),
      namaLengkap: row['nama_lengkap']?.toString() ?? '',
      email: row['email']?.toString() ?? '',
      peran: row['peran']?.toString() ?? 'karyawan',
      aktif: row['aktif'] == true,
      sandiLogin: row['sandi_login']?.toString(),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  11.2 Penyimpanan transaksi + detail + kurangi stok
&lt;/h3&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/lib/data/sources/transaksi_sumber.dart&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Alur: hitung subtotal dari &lt;code&gt;ItemKeranjang&lt;/code&gt; → validasi &lt;code&gt;totalAkhir&lt;/code&gt; → &lt;code&gt;insert&lt;/code&gt; ke tabel &lt;code&gt;transaksi&lt;/code&gt; → &lt;code&gt;insert&lt;/code&gt; batch ke &lt;code&gt;detail_transaksi&lt;/code&gt; → loop &lt;code&gt;kurangiStokSetelahPenjualan&lt;/code&gt; per item.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Future&amp;lt;String?&amp;gt; simpanTransaksi({
    String? idPengguna,
    required List&amp;lt;ItemKeranjang&amp;gt; itemKeranjang,
    required String metodePembayaran,
    int? totalAkhir,
  }) async {
    if (itemKeranjang.isEmpty) {
      return null;
    }

    final subtotal = itemKeranjang.fold&amp;lt;int&amp;gt;(
      0,
      (sebelumnya, item) =&amp;gt; sebelumnya + item.subtotal,
    );
    final total = totalAkhir ?? subtotal;
    if (total &amp;lt; 0 || total &amp;gt; subtotal) {
      throw ArgumentError(
        'totalAkhir harus antara 0 dan subtotal ($subtotal), dapat: $total',
      );
    }
    final potongan = subtotal - total;

    final barisTransaksi = &amp;lt;String, dynamic&amp;gt;{
      'waktu': DateTime.now().toUtc().toIso8601String(),
      'subtotal': subtotal,
      'potongan': potongan,
      'total': total,
      'metode_pembayaran': metodePembayaran,
    };
    if (idPengguna != null) {
      barisTransaksi['id_pengguna'] = idPengguna;
    }

    final responsTransaksi = await supabaseKlien
        .from('transaksi')
        .insert(barisTransaksi)
        .select('id')
        .single();
// ...
    await supabaseKlien.from('detail_transaksi').insert(detail);

    for (final item in itemKeranjang) {
      await _stokSumber.kurangiStokSetelahPenjualan(
        produkId: item.produk.id,
        kuantitasTerjual: item.kuantitas,
      );
    }

    return transaksiId;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  11.3 Peta berkas sumber data (ringkas)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Berkas&lt;/th&gt;
&lt;th&gt;Tabel / bucket (umum)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pengguna_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pengguna&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;profil_karyawan_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;profil_karyawan&lt;/code&gt;, bucket &lt;code&gt;karyawan-foto&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;produk_dan_stok_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;produk&lt;/code&gt;, &lt;code&gt;stok&lt;/code&gt;, bucket &lt;code&gt;produk-gambar&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transaksi_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transaksi&lt;/code&gt;, &lt;code&gt;detail_transaksi&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dashboard_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;agregat dari &lt;code&gt;transaksi&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;laporan_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transaksi&lt;/code&gt; (+ relasi ke &lt;code&gt;pengguna&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;log_aktivitas_sumber.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;log_aktivitas&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;supabase_klien.dart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inisialisasi + tes koneksi ke &lt;code&gt;pengguna&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  12. Modul fitur (&lt;code&gt;lib/features/&lt;/code&gt;) — cakupan untuk demo
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Layar utama&lt;/th&gt;
&lt;th&gt;Catatan ujian&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pembuka owner pertama, login, daftar owner&lt;/td&gt;
&lt;td&gt;Alur belum ada owner di DB vs lanjut kasir&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dashboard/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dashboard, shell nav&lt;/td&gt;
&lt;td&gt;Carousel, pintasan ke produk/stok/laporan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pos/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Menu kasir, POS, pencatatan transaksi&lt;/td&gt;
&lt;td&gt;Keranjang, diskon, notifikasi in-app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;produk/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Daftar &amp;amp; form produk&lt;/td&gt;
&lt;td&gt;Upload gambar ke storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stok/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stok&lt;/td&gt;
&lt;td&gt;Penyesuaian kuantitas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;karyawan/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Daftar &amp;amp; form&lt;/td&gt;
&lt;td&gt;Operasi admin (kaitkan dengan service role)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;laporan/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Laporan penjualan&lt;/td&gt;
&lt;td&gt;PDF/CSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;log_aktivitas/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Log aktivitas&lt;/td&gt;
&lt;td&gt;Owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pengaturan/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Profil/saya, tentang, panel transaksi terakhir karyawan&lt;/td&gt;
&lt;td&gt;Perbedaan menu owner vs karyawan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  13. Pengujian otomatis
&lt;/h2&gt;

&lt;p&gt;Berkas: &lt;code&gt;sarypos/test/skeleton_sarypos_test.dart&lt;/code&gt; — hanya uji widget skeleton. &lt;strong&gt;Tidak ada&lt;/strong&gt; test integrasi Supabase atau alur POS; ini jawaban jujur jika dosen menanyakan cakupan test.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Pertanyaan yang sering muncul saat pengujian (beserta jawaban singkat)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mengapa memakai &lt;code&gt;IndexedStack&lt;/code&gt; untuk tab?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Agar state tiap tab (mis. scroll posisi) tidak hilang saat ganti tab; hanya tab aktif yang “terlihat”, sisanya tetap di pohon widget.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bagaimana cara aplikasi tahu user owner?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Dari baris &lt;code&gt;pengguna&lt;/code&gt; di Supabase: field &lt;code&gt;peran&lt;/code&gt;; di kode &lt;code&gt;PenggunaModel.isOwner&lt;/code&gt; membandingkan dengan &lt;code&gt;'owner'&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Di mana pembatasan fitur owner diimplementasikan?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Kombinasi &lt;code&gt;dorongJikaOwner&lt;/code&gt;, &lt;code&gt;penggunaAdalahOwner&lt;/code&gt;, &lt;code&gt;cegahJikaBelumLogin&lt;/code&gt;, dan kondisi UI di &lt;code&gt;HalamanUtamaDenganNav&lt;/code&gt; / halaman pengaturan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bagaimana state tema diwariskan?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;WarisanTema&lt;/code&gt; (&lt;code&gt;InheritedNotifier&amp;lt;PengaturTema&amp;gt;&lt;/code&gt;); &lt;code&gt;AppBarSarypos&lt;/code&gt; memanggil &lt;code&gt;WarisanTema.dari(context)&lt;/code&gt; untuk mengubah tema.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GetX dipakai untuk apa saja?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Terutama &lt;code&gt;GetMaterialApp&lt;/code&gt; dan navigasi/dialog di &lt;code&gt;dorongJikaOwner&lt;/code&gt;; tidak ada pola reactive massal (&lt;code&gt;Obx&lt;/code&gt; massal).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bagaimana alur simpan transaksi?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;TransaksiSumber.simpanTransaksi&lt;/code&gt;: insert header &lt;code&gt;transaksi&lt;/code&gt;, insert lines &lt;code&gt;detail_transaksi&lt;/code&gt;, lalu kurangi stok per produk.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Di mana konfigurasi Supabase?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;supabase_konfigurasi.dart&lt;/code&gt;; wajib URL + anon key dari &lt;code&gt;.env&lt;/code&gt; atau &lt;code&gt;--dart-define&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apa risiko &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; di aplikasi?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Kunci ini melewati RLS; untuk produksi seharusnya tidak dibundel di klien. Untuk proyek kuliah, siapkan penjelasan pembatasan dan skenario ideal (Edge Function/server).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  15. Teks pegangan (ringkas, untuk dibaca sebelum masuk ruang ujian)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Akar widget:&lt;/strong&gt; &lt;code&gt;main.dart&lt;/code&gt; → &lt;code&gt;MainApp&lt;/code&gt; → &lt;code&gt;GetMaterialApp&lt;/code&gt; + &lt;code&gt;Stack&lt;/code&gt; banner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiga warisan:&lt;/strong&gt; Tema (&lt;code&gt;WarisanTema&lt;/code&gt;), Sesi (&lt;code&gt;WarisanSesi&lt;/code&gt;), Notifikasi (&lt;code&gt;WarisanNotifikasiInApp&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home dinamis:&lt;/strong&gt; loading → error (&lt;code&gt;HalamanErrorKoneksi&lt;/code&gt;) → pembuka owner → &lt;code&gt;HalamanUtamaDenganNav&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tab:&lt;/strong&gt; Beranda = &lt;code&gt;HalamanDashboard&lt;/code&gt;, Kasir = &lt;code&gt;HalamanKasirMenu&lt;/code&gt;, Saya = &lt;code&gt;HalamanUserPengaturan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kartu UI:&lt;/strong&gt; &lt;code&gt;CardSarypos&lt;/code&gt; = wadah visual; &lt;code&gt;CardRingkasan&lt;/code&gt; = pola ringkasan angka + ikon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feedback:&lt;/strong&gt; snackbar = &lt;code&gt;tampilkanSnackbarSarypos&lt;/code&gt;; banner atas = &lt;code&gt;PengelolaNotifikasiInApp&lt;/code&gt; + &lt;code&gt;BannerNotifikasiInApp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard:&lt;/strong&gt; &lt;code&gt;cegahJikaBelumLogin&lt;/code&gt; (snackbar info); &lt;code&gt;dorongJikaOwner&lt;/code&gt; (dialog + &lt;code&gt;Get.to&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; model di &lt;code&gt;lib/data/models/&lt;/code&gt;, akses Supabase di &lt;code&gt;lib/data/sources/&lt;/code&gt;, klien tunggal &lt;code&gt;supabaseKlien&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tema warna:&lt;/strong&gt; &lt;code&gt;WarnaSarypos&lt;/code&gt; + &lt;code&gt;temaSaryposTerang&lt;/code&gt;/&lt;code&gt;Gelap&lt;/code&gt; + font Plus Jakarta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaksi:&lt;/strong&gt; tabel &lt;code&gt;transaksi&lt;/code&gt; + &lt;code&gt;detail_transaksi&lt;/code&gt;, stok dikurangi setelah sukses insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bukti di kode:&lt;/strong&gt; selalu bisa buka berkas yang dicantumkan di atas dan tunjukkan blok baris yang sama dengan dokumen ini.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Dokumen ini disusun berdasarkan audit struktural codebase SaryPOS pada repositori lokal Anda; jika ada perubahan signifikan setelah commit tertentu, sesuaikan referensi baris dengan mencari ulang simbol yang sama di IDE.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>flutter</category>
      <category>mobile</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
