From 078d74aad31ea1c89b067bc044ad61b714788ee9 Mon Sep 17 00:00:00 2001 From: Khafidh Fuadi Date: Sun, 16 Mar 2025 16:30:23 +0700 Subject: [PATCH] Perbarui struktur dan referensi file di dashboard_view.dart dan detail_donatur_view.dart. Tambahkan dokumentasi pada kelas DateTimeHelper dan perkenalan fungsi baru untuk format tanggal relatif serta nama hari dan bulan. Hapus widget yang tidak digunakan seperti detail_penitipan_dialog.dart, loading_indicator.dart, navigation_button.dart, statistic_card.dart, dan status_pill.dart untuk menyederhanakan kode. --- .../petugas_desa/views/dashboard_view.dart | 2 +- .../views/detail_donatur_view.dart | 2 +- .../petugas_desa/views/penitipan_view.dart | 2 +- lib/app/utils/date_time_helper.dart | 127 +++++++- .../widgets/buttons/navigation_button.dart | 120 +++++++ lib/app/widgets/buttons/primary_button.dart | 118 +++++++ lib/app/widgets/buttons/secondary_button.dart | 111 +++++++ lib/app/widgets/cards/info_card.dart | 135 ++++++++ lib/app/widgets/cards/statistic_card.dart | 111 +++++++ lib/app/widgets/custom_app_bar.dart | 45 ++- lib/app/widgets/detail_penitipan_dialog.dart | 306 ------------------ .../widgets/dialogs/confirmation_dialog.dart | 100 ++++++ .../dialogs/detail_penitipan_dialog.dart | 215 ++++++++++++ .../indicators/full_screen_loading.dart | 59 ++++ .../{ => indicators}/loading_indicator.dart | 33 +- lib/app/widgets/indicators/status_pill.dart | 103 ++++++ lib/app/widgets/inputs/dropdown_input.dart | 164 ++++++++++ lib/app/widgets/inputs/text_input.dart | 193 +++++++++++ lib/app/widgets/navigation_button.dart | 79 ----- lib/app/widgets/statistic_card.dart | 61 ---- lib/app/widgets/status_pill.dart | 35 -- lib/app/widgets/widgets.dart | 27 ++ 22 files changed, 1639 insertions(+), 509 deletions(-) create mode 100644 lib/app/widgets/buttons/navigation_button.dart create mode 100644 lib/app/widgets/buttons/primary_button.dart create mode 100644 lib/app/widgets/buttons/secondary_button.dart create mode 100644 lib/app/widgets/cards/info_card.dart create mode 100644 lib/app/widgets/cards/statistic_card.dart delete mode 100644 lib/app/widgets/detail_penitipan_dialog.dart create mode 100644 lib/app/widgets/dialogs/confirmation_dialog.dart create mode 100644 lib/app/widgets/dialogs/detail_penitipan_dialog.dart create mode 100644 lib/app/widgets/indicators/full_screen_loading.dart rename lib/app/widgets/{ => indicators}/loading_indicator.dart (55%) create mode 100644 lib/app/widgets/indicators/status_pill.dart create mode 100644 lib/app/widgets/inputs/dropdown_input.dart create mode 100644 lib/app/widgets/inputs/text_input.dart delete mode 100644 lib/app/widgets/navigation_button.dart delete mode 100644 lib/app/widgets/statistic_card.dart delete mode 100644 lib/app/widgets/status_pill.dart create mode 100644 lib/app/widgets/widgets.dart diff --git a/lib/app/modules/petugas_desa/views/dashboard_view.dart b/lib/app/modules/petugas_desa/views/dashboard_view.dart index 320507e..600d79b 100644 --- a/lib/app/modules/petugas_desa/views/dashboard_view.dart +++ b/lib/app/modules/petugas_desa/views/dashboard_view.dart @@ -5,7 +5,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/components/progress_sect import 'package:penyaluran_app/app/modules/petugas_desa/components/schedule_card.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/petugas_desa_controller.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; -import 'package:penyaluran_app/app/widgets/statistic_card.dart'; +import 'package:penyaluran_app/app/widgets/cards/statistic_card.dart'; class DashboardView extends GetView { const DashboardView({super.key}); diff --git a/lib/app/modules/petugas_desa/views/detail_donatur_view.dart b/lib/app/modules/petugas_desa/views/detail_donatur_view.dart index 0c28326..b75ad9f 100644 --- a/lib/app/modules/petugas_desa/views/detail_donatur_view.dart +++ b/lib/app/modules/petugas_desa/views/detail_donatur_view.dart @@ -4,7 +4,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/controllers/donatur_cont import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/data/models/donatur_model.dart'; import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; -import 'package:penyaluran_app/app/widgets/detail_penitipan_dialog.dart'; +import 'package:penyaluran_app/app/widgets/dialogs/detail_penitipan_dialog.dart'; import 'package:penyaluran_app/app/utils/date_time_helper.dart'; class DetailDonaturView extends GetView { diff --git a/lib/app/modules/petugas_desa/views/penitipan_view.dart b/lib/app/modules/petugas_desa/views/penitipan_view.dart index 78ca004..02be88d 100644 --- a/lib/app/modules/petugas_desa/views/penitipan_view.dart +++ b/lib/app/modules/petugas_desa/views/penitipan_view.dart @@ -5,7 +5,7 @@ import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_bantuan_controller.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/utils/date_time_helper.dart'; -import 'package:penyaluran_app/app/widgets/detail_penitipan_dialog.dart'; +import 'package:penyaluran_app/app/widgets/dialogs/detail_penitipan_dialog.dart'; import 'dart:io'; class PenitipanView extends GetView { diff --git a/lib/app/utils/date_time_helper.dart b/lib/app/utils/date_time_helper.dart index f0810d3..dd47fba 100644 --- a/lib/app/utils/date_time_helper.dart +++ b/lib/app/utils/date_time_helper.dart @@ -1,5 +1,9 @@ import 'package:intl/intl.dart'; +/// Kelas pembantu untuk manipulasi tanggal dan waktu +/// +/// Kelas ini berisi fungsi-fungsi untuk memformat dan memanipulasi +/// tanggal dan waktu. class DateTimeHelper { /// Mengkonversi DateTime dari UTC ke timezone lokal static DateTime toLocalDateTime(DateTime utcDateTime) { @@ -7,10 +11,17 @@ class DateTimeHelper { } /// Format tanggal ke format Indonesia (dd MMM yyyy) - static String formatDate(DateTime? dateTime, - {String format = 'dd MMM yyyy', - String locale = 'id_ID', - String defaultValue = 'Belum ditentukan'}) { + /// + /// [dateTime] adalah DateTime yang akan diformat + /// [format] adalah format yang digunakan + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String formatDate( + DateTime? dateTime, { + String format = 'dd MMM yyyy', + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { if (dateTime == null) return defaultValue; // Pastikan tanggal dalam timezone lokal @@ -24,10 +35,17 @@ class DateTimeHelper { } /// Format waktu ke format 24 jam (HH:mm) - static String formatTime(DateTime? dateTime, - {String format = 'HH:mm', - String locale = 'id_ID', - String defaultValue = 'Belum ditentukan'}) { + /// + /// [dateTime] adalah DateTime yang akan diformat + /// [format] adalah format yang digunakan + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String formatTime( + DateTime? dateTime, { + String format = 'HH:mm', + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { if (dateTime == null) return defaultValue; // Pastikan waktu dalam timezone lokal @@ -44,10 +62,17 @@ class DateTimeHelper { } /// Format tanggal dan waktu (dd MMM yyyy HH:mm) - static String formatDateTime(DateTime? dateTime, - {String format = 'dd MMM yyyy HH:mm', - String locale = 'id_ID', - String defaultValue = 'Belum ditentukan'}) { + /// + /// [dateTime] adalah DateTime yang akan diformat + /// [format] adalah format yang digunakan + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String formatDateTime( + DateTime? dateTime, { + String format = 'dd MMM yyyy HH:mm', + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { if (dateTime == null) return defaultValue; // Pastikan tanggal dan waktu dalam timezone lokal @@ -56,7 +81,83 @@ class DateTimeHelper { return DateFormat(format, locale).format(localDateTime); } catch (e) { print('Error formatting date time: $e'); - return localDateTime.toString().split('.')[0]; // Fallback to basic format + return localDateTime.toString(); // Fallback to basic format + } + } + + /// Format tanggal relatif (hari ini, kemarin, dll) + /// + /// [dateTime] adalah DateTime yang akan diformat + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String formatRelativeDate( + DateTime? dateTime, { + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { + if (dateTime == null) return defaultValue; + + // Pastikan tanggal dalam timezone lokal + final localDateTime = toLocalDateTime(dateTime); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final tomorrow = today.add(const Duration(days: 1)); + final aDate = + DateTime(localDateTime.year, localDateTime.month, localDateTime.day); + + if (aDate == today) { + return 'Hari ini, ${formatTime(localDateTime)}'; + } else if (aDate == yesterday) { + return 'Kemarin, ${formatTime(localDateTime)}'; + } else if (aDate == tomorrow) { + return 'Besok, ${formatTime(localDateTime)}'; + } else { + return formatDateTime(localDateTime); + } + } + + /// Mendapatkan nama hari dari DateTime + /// + /// [dateTime] adalah DateTime yang akan diambil nama harinya + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String getDayName( + DateTime? dateTime, { + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { + if (dateTime == null) return defaultValue; + + // Pastikan tanggal dalam timezone lokal + final localDateTime = toLocalDateTime(dateTime); + try { + return DateFormat('EEEE', locale).format(localDateTime); + } catch (e) { + print('Error getting day name: $e'); + return ''; // Fallback to empty string + } + } + + /// Mendapatkan nama bulan dari DateTime + /// + /// [dateTime] adalah DateTime yang akan diambil nama bulannya + /// [locale] adalah locale yang digunakan + /// [defaultValue] adalah nilai default jika dateTime null + static String getMonthName( + DateTime? dateTime, { + String locale = 'id_ID', + String defaultValue = 'Belum ditentukan', + }) { + if (dateTime == null) return defaultValue; + + // Pastikan tanggal dalam timezone lokal + final localDateTime = toLocalDateTime(dateTime); + try { + return DateFormat('MMMM', locale).format(localDateTime); + } catch (e) { + print('Error getting month name: $e'); + return ''; // Fallback to empty string } } diff --git a/lib/app/widgets/buttons/navigation_button.dart b/lib/app/widgets/buttons/navigation_button.dart new file mode 100644 index 0000000..3c2f74e --- /dev/null +++ b/lib/app/widgets/buttons/navigation_button.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Tombol navigasi yang digunakan untuk navigasi dalam aplikasi +/// +/// Tombol ini memiliki label dan ikon, dan dapat dikonfigurasi untuk +/// berbagai ukuran dan warna. +class NavigationButton extends StatelessWidget { + /// Label yang ditampilkan pada tombol + final String label; + + /// Ikon yang ditampilkan di sebelah label (opsional) + final IconData? icon; + + /// Widget ikon kustom yang ditampilkan di sebelah label (opsional) + final Widget? iconWidget; + + /// Fungsi yang dipanggil ketika tombol ditekan + final VoidCallback onPressed; + + /// Warna latar belakang tombol + final Color? backgroundColor; + + /// Warna teks dan ikon + final Color? foregroundColor; + + /// Ukuran teks + final double fontSize; + + /// Ukuran ikon + final double iconSize; + + /// Konstruktor untuk NavigationButton + /// + /// Salah satu dari [icon] atau [iconWidget] harus disediakan. + const NavigationButton({ + super.key, + required this.label, + this.icon, + this.iconWidget, + required this.onPressed, + this.backgroundColor, + this.foregroundColor, + this.fontSize = 10, + this.iconSize = 12, + }) : assert(icon != null || iconWidget != null, + 'Either icon or iconWidget must be provided'); + + @override + Widget build(BuildContext context) { + final Color bgColor = backgroundColor ?? AppColors.primary; + final Color fgColor = foregroundColor ?? const Color(0xFFAFF8FF); + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 70), + child: GestureDetector( + onTap: onPressed, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: fgColor, + ), + ), + const SizedBox(width: 4), + if (icon != null) + Icon( + icon, + size: iconSize, + color: fgColor, + ) + else if (iconWidget != null) + SizedBox( + width: iconSize, + height: iconSize, + child: iconWidget, + ), + ], + ), + ), + ), + ); + } +} + +/// Data class untuk tombol navigasi +/// +/// Digunakan untuk menyimpan data tombol navigasi yang akan digunakan +/// di beberapa tempat. +class NavigationButtonData { + /// Label yang ditampilkan pada tombol + final String label; + + /// Ikon yang ditampilkan di sebelah label (opsional) + final IconData? icon; + + /// Widget ikon kustom yang ditampilkan di sebelah label (opsional) + final Widget? iconWidget; + + /// Konstruktor untuk NavigationButtonData + /// + /// Salah satu dari [icon] atau [iconWidget] harus disediakan. + const NavigationButtonData({ + required this.label, + this.icon, + this.iconWidget, + }) : assert(icon != null || iconWidget != null, + 'Either icon or iconWidget must be provided'); +} diff --git a/lib/app/widgets/buttons/primary_button.dart b/lib/app/widgets/buttons/primary_button.dart new file mode 100644 index 0000000..8470a44 --- /dev/null +++ b/lib/app/widgets/buttons/primary_button.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Tombol utama yang digunakan di seluruh aplikasi +/// +/// Tombol ini memiliki warna latar belakang utama dan teks putih. +/// Dapat dikonfigurasi untuk berbagai ukuran dan dapat dinonaktifkan. +class PrimaryButton extends StatelessWidget { + /// Teks yang ditampilkan pada tombol + final String text; + + /// Fungsi yang dipanggil ketika tombol ditekan + final VoidCallback? onPressed; + + /// Ikon yang ditampilkan di sebelah kiri teks (opsional) + final IconData? icon; + + /// Apakah tombol mengisi lebar penuh + final bool fullWidth; + + /// Ukuran tombol (kecil, sedang, besar) + final ButtonSize size; + + /// Apakah tombol sedang memuat + final bool isLoading; + + const PrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.fullWidth = false, + this.size = ButtonSize.medium, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + // Tentukan padding berdasarkan ukuran + final EdgeInsetsGeometry padding = _getPadding(); + + // Tentukan ukuran teks berdasarkan ukuran tombol + final double fontSize = _getFontSize(); + + return SizedBox( + width: fullWidth ? double.infinity : null, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: fontSize + 2), + const SizedBox(width: 8), + ], + Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + /// Mendapatkan padding berdasarkan ukuran tombol + EdgeInsetsGeometry _getPadding() { + switch (size) { + case ButtonSize.small: + return const EdgeInsets.symmetric(horizontal: 12, vertical: 8); + case ButtonSize.medium: + return const EdgeInsets.symmetric(horizontal: 16, vertical: 12); + case ButtonSize.large: + return const EdgeInsets.symmetric(horizontal: 24, vertical: 16); + } + } + + /// Mendapatkan ukuran font berdasarkan ukuran tombol + double _getFontSize() { + switch (size) { + case ButtonSize.small: + return 12; + case ButtonSize.medium: + return 14; + case ButtonSize.large: + return 16; + } + } +} + +/// Enum untuk ukuran tombol +enum ButtonSize { + small, + medium, + large, +} diff --git a/lib/app/widgets/buttons/secondary_button.dart b/lib/app/widgets/buttons/secondary_button.dart new file mode 100644 index 0000000..695e656 --- /dev/null +++ b/lib/app/widgets/buttons/secondary_button.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; +import 'package:penyaluran_app/app/widgets/buttons/primary_button.dart'; + +/// Tombol sekunder yang digunakan di seluruh aplikasi +/// +/// Tombol ini memiliki warna latar belakang putih dengan border dan teks berwarna utama. +/// Dapat dikonfigurasi untuk berbagai ukuran dan dapat dinonaktifkan. +class SecondaryButton extends StatelessWidget { + /// Teks yang ditampilkan pada tombol + final String text; + + /// Fungsi yang dipanggil ketika tombol ditekan + final VoidCallback? onPressed; + + /// Ikon yang ditampilkan di sebelah kiri teks (opsional) + final IconData? icon; + + /// Apakah tombol mengisi lebar penuh + final bool fullWidth; + + /// Ukuran tombol (kecil, sedang, besar) + final ButtonSize size; + + /// Apakah tombol sedang memuat + final bool isLoading; + + const SecondaryButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.fullWidth = false, + this.size = ButtonSize.medium, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + // Tentukan padding berdasarkan ukuran + final EdgeInsetsGeometry padding = _getPadding(); + + // Tentukan ukuran teks berdasarkan ukuran tombol + final double fontSize = _getFontSize(); + + return SizedBox( + width: fullWidth ? double.infinity : null, + child: OutlinedButton( + onPressed: isLoading ? null : onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: BorderSide(color: AppColors.primary), + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: fontSize + 2), + const SizedBox(width: 8), + ], + Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + /// Mendapatkan padding berdasarkan ukuran tombol + EdgeInsetsGeometry _getPadding() { + switch (size) { + case ButtonSize.small: + return const EdgeInsets.symmetric(horizontal: 12, vertical: 8); + case ButtonSize.medium: + return const EdgeInsets.symmetric(horizontal: 16, vertical: 12); + case ButtonSize.large: + return const EdgeInsets.symmetric(horizontal: 24, vertical: 16); + } + } + + /// Mendapatkan ukuran font berdasarkan ukuran tombol + double _getFontSize() { + switch (size) { + case ButtonSize.small: + return 12; + case ButtonSize.medium: + return 14; + case ButtonSize.large: + return 16; + } + } +} diff --git a/lib/app/widgets/cards/info_card.dart b/lib/app/widgets/cards/info_card.dart new file mode 100644 index 0000000..fa7451f --- /dev/null +++ b/lib/app/widgets/cards/info_card.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Kartu informasi yang digunakan untuk menampilkan informasi +/// +/// Kartu ini memiliki judul, deskripsi, dan ikon, dan dapat dikonfigurasi +/// untuk berbagai ukuran dan warna. +class InfoCard extends StatelessWidget { + /// Judul kartu + final String title; + + /// Deskripsi atau konten kartu + final String description; + + /// Ikon yang ditampilkan di kartu (opsional) + final IconData? icon; + + /// Widget ikon kustom yang ditampilkan di kartu (opsional) + final Widget? iconWidget; + + /// Warna latar belakang kartu + final Color? backgroundColor; + + /// Warna judul + final Color? titleColor; + + /// Warna deskripsi + final Color? descriptionColor; + + /// Warna ikon + final Color? iconColor; + + /// Fungsi yang dipanggil ketika kartu ditekan (opsional) + final VoidCallback? onTap; + + /// Padding kartu + final EdgeInsetsGeometry padding; + + /// Margin kartu + final EdgeInsetsGeometry margin; + + /// Konstruktor untuk InfoCard + const InfoCard({ + super.key, + required this.title, + required this.description, + this.icon, + this.iconWidget, + this.backgroundColor, + this.titleColor, + this.descriptionColor, + this.iconColor, + this.onTap, + this.padding = const EdgeInsets.all(16), + this.margin = const EdgeInsets.all(0), + }) : assert( + icon != null || + iconWidget != null || + (icon == null && iconWidget == null), + 'Cannot provide both icon and iconWidget'); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final Color bgColor = backgroundColor ?? Colors.white; + final Color titleTextColor = titleColor ?? AppColors.textPrimary; + final Color descTextColor = descriptionColor ?? AppColors.textSecondary; + final Color iconColorValue = iconColor ?? AppColors.primary; + + return Card( + margin: margin, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Icon( + icon, + size: 24, + color: iconColorValue, + ), + ) + else if (iconWidget != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 24, + height: 24, + child: iconWidget, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + color: titleTextColor, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: textTheme.bodyMedium?.copyWith( + fontSize: 14, + color: descTextColor, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/widgets/cards/statistic_card.dart b/lib/app/widgets/cards/statistic_card.dart new file mode 100644 index 0000000..5ce1d7f --- /dev/null +++ b/lib/app/widgets/cards/statistic_card.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; + +/// Kartu statistik yang digunakan untuk menampilkan data statistik +/// +/// Kartu ini memiliki judul, jumlah, dan subtitle, dan dapat dikonfigurasi +/// untuk berbagai ukuran dan warna. +class StatisticCard extends StatelessWidget { + /// Judul kartu + final String title; + + /// Jumlah atau nilai statistik + final String count; + + /// Subtitle atau deskripsi tambahan + final String subtitle; + + /// Tinggi kartu + final double height; + + /// Gradient latar belakang kartu + final Gradient? gradient; + + /// Warna teks + final Color textColor; + + /// Ikon yang ditampilkan di kartu (opsional) + final IconData? icon; + + /// Warna ikon + final Color? iconColor; + + /// Konstruktor untuk StatisticCard + const StatisticCard({ + super.key, + required this.title, + required this.count, + required this.subtitle, + this.height = 100, + this.gradient, + this.textColor = Colors.white, + this.icon, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final Gradient backgroundGradient = gradient ?? AppTheme.primaryGradient; + final Color iconColorValue = iconColor ?? textColor; + + return Container( + width: double.infinity, + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: backgroundGradient, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(26), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyMedium?.copyWith( + fontSize: 14, + color: textColor.withAlpha(204), + ), + ), + const SizedBox(height: 8), + Text( + count, + style: textTheme.headlineSmall?.copyWith( + fontSize: 24, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + const Spacer(), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + fontSize: 12, + color: textColor, + ), + ), + ], + ), + ), + if (icon != null) + Icon( + icon, + size: 40, + color: iconColorValue.withAlpha(51), + ), + ], + ), + ); + } +} diff --git a/lib/app/widgets/custom_app_bar.dart b/lib/app/widgets/custom_app_bar.dart index 1b0c6c2..6393438 100644 --- a/lib/app/widgets/custom_app_bar.dart +++ b/lib/app/widgets/custom_app_bar.dart @@ -1,17 +1,39 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; +/// AppBar kustom yang digunakan di seluruh aplikasi +/// +/// AppBar ini dapat dikonfigurasi untuk berbagai tampilan dan fungsi. class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Judul yang ditampilkan di AppBar final String title; + + /// Apakah menampilkan tombol kembali final bool showBackButton; + + /// Daftar aksi yang ditampilkan di sebelah kanan AppBar final List? actions; + + /// Widget yang ditampilkan di sebelah kiri AppBar final Widget? leading; + + /// Apakah judul berada di tengah final bool centerTitle; + + /// Elevasi AppBar final double elevation; + + /// Warna latar belakang AppBar final Color? backgroundColor; + + /// Warna konten AppBar final Color? foregroundColor; + /// Fungsi yang dipanggil ketika tombol kembali ditekan + final VoidCallback? onBackPressed; + + /// Konstruktor untuk CustomAppBar const CustomAppBar({ super.key, required this.title, @@ -22,6 +44,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { this.elevation = 0, this.backgroundColor, this.foregroundColor, + this.onBackPressed, }); @override @@ -37,18 +60,24 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { ), centerTitle: centerTitle, elevation: elevation, - backgroundColor: backgroundColor ?? AppTheme.primaryColor, + backgroundColor: backgroundColor ?? AppColors.primary, foregroundColor: foregroundColor ?? Colors.white, - leading: showBackButton - ? IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Get.back(), - ) - : leading, + leading: _buildLeading(), actions: actions, ); } + /// Membangun widget leading berdasarkan parameter + Widget? _buildLeading() { + if (showBackButton) { + return IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: onBackPressed ?? () => Get.back(), + ); + } + return leading; + } + @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } diff --git a/lib/app/widgets/detail_penitipan_dialog.dart b/lib/app/widgets/detail_penitipan_dialog.dart deleted file mode 100644 index 8e2413d..0000000 --- a/lib/app/widgets/detail_penitipan_dialog.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; -import 'package:penyaluran_app/app/utils/date_time_helper.dart'; - -/// Dialog untuk menampilkan detail penitipan bantuan -/// -/// Contoh penggunaan: -/// ```dart -/// // Di halaman lain -/// void showDetailPenitipan(BuildContext context, PenitipanBantuanModel item) { -/// // Dapatkan data yang diperlukan -/// final donaturNama = item.donatur?.nama ?? 'Donatur tidak ditemukan'; -/// final kategoriNama = item.kategoriBantuan?.nama ?? 'Kategori tidak ditemukan'; -/// final kategoriSatuan = item.kategoriBantuan?.satuan ?? ''; -/// -/// // Tampilkan dialog -/// DetailPenitipanDialog.show( -/// context: context, -/// item: item, -/// donaturNama: donaturNama, -/// kategoriNama: kategoriNama, -/// kategoriSatuan: kategoriSatuan, -/// getPetugasDesaNama: (String? id) => 'Nama Petugas', // Sesuaikan dengan cara mendapatkan nama petugas -/// showFullScreenImage: (String imageUrl) { -/// DetailPenitipanDialog.showFullScreenImage(context, imageUrl); -/// }, -/// ); -/// } -class DetailPenitipanDialog { - static void show({ - required BuildContext context, - required PenitipanBantuanModel item, - required String donaturNama, - required String kategoriNama, - required String kategoriSatuan, - required String Function(String?) getPetugasDesaNama, - required Function(String) showFullScreenImage, - }) { - // Cek apakah penitipan berbentuk uang - final isUang = item.isUang ?? false; - - Get.dialog( - AlertDialog( - title: const Text('Detail Penitipan'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailItem('Donatur', donaturNama), - _buildDetailItem('Status', item.status ?? 'Tidak diketahui'), - _buildDetailItem('Kategori Bantuan', kategoriNama), - _buildDetailItem( - 'Jumlah', - isUang - ? 'Rp ${DateTimeHelper.formatNumber(item.jumlah)}' - : '${DateTimeHelper.formatNumber(item.jumlah)} $kategoriSatuan'), - if (isUang) _buildDetailItem('Jenis Bantuan', 'Uang (Rupiah)'), - _buildDetailItem( - 'Deskripsi', item.deskripsi ?? 'Tidak ada deskripsi'), - _buildDetailItem( - 'Tanggal Penitipan', - DateTimeHelper.formatDateTime(item.tanggalPenitipan, - defaultValue: 'Tidak ada tanggal'), - ), - if (item.tanggalVerifikasi != null) - _buildDetailItem( - 'Tanggal Verifikasi', - DateTimeHelper.formatDateTime(item.tanggalVerifikasi), - ), - if (item.status == 'TERVERIFIKASI' && item.petugasDesaId != null) - _buildDetailItem( - 'Diverifikasi Oleh', - getPetugasDesaNama(item.petugasDesaId), - ), - _buildDetailItem('Tanggal Dibuat', - DateTimeHelper.formatDateTime(item.createdAt)), - if (item.alasanPenolakan != null && - item.alasanPenolakan!.isNotEmpty) - _buildDetailItem('Alasan Penolakan', item.alasanPenolakan!), - - // Foto Bantuan - if (!isUang && - item.fotoBantuan != null && - item.fotoBantuan!.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - const Text( - 'Foto Bantuan:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: item.fotoBantuan!.length, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - showFullScreenImage(item.fotoBantuan![index]); - }, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - item.fotoBantuan![index], - height: 100, - width: 100, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 100, - width: 100, - color: Colors.grey.shade300, - child: const Icon(Icons.error), - ); - }, - ), - ), - ), - ); - }, - ), - ), - ], - ), - - // Bukti Transfer (untuk bantuan uang) - if (isUang && - item.fotoBantuan != null && - item.fotoBantuan!.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - const Text( - 'Bukti Transfer:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: item.fotoBantuan!.length, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - showFullScreenImage(item.fotoBantuan![index]); - }, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - item.fotoBantuan![index], - height: 100, - width: 100, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 100, - width: 100, - color: Colors.grey.shade300, - child: const Icon(Icons.error), - ); - }, - ), - ), - ), - ); - }, - ), - ), - ], - ), - - // Bukti Serah Terima - if (item.fotoBuktiSerahTerima != null && - item.fotoBuktiSerahTerima!.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - const Text( - 'Bukti Serah Terima:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () { - showFullScreenImage(item.fotoBuktiSerahTerima!); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - item.fotoBuktiSerahTerima!, - height: 200, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 200, - width: double.infinity, - color: Colors.grey.shade300, - child: const Icon(Icons.error), - ); - }, - ), - ), - ), - ], - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('Tutup'), - ), - ], - ), - ); - } - - static Widget _buildDetailItem(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - Text( - value, - style: const TextStyle(fontSize: 14), - ), - const Divider(), - ], - ), - ); - } - - static void showFullScreenImage(BuildContext context, String imageUrl) { - Get.dialog( - Dialog( - insetPadding: EdgeInsets.zero, - child: Stack( - fit: StackFit.expand, - children: [ - InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey.shade300, - child: const Center( - child: Icon( - Icons.error, - size: 50, - color: Colors.red, - ), - ), - ); - }, - ), - ), - Positioned( - top: 20, - right: 20, - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/app/widgets/dialogs/confirmation_dialog.dart b/lib/app/widgets/dialogs/confirmation_dialog.dart new file mode 100644 index 0000000..b6a71b0 --- /dev/null +++ b/lib/app/widgets/dialogs/confirmation_dialog.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Dialog konfirmasi yang digunakan di seluruh aplikasi +/// +/// Dialog ini dapat dikonfigurasi untuk berbagai jenis konfirmasi. +class ConfirmationDialog { + /// Menampilkan dialog konfirmasi + /// + /// [title] adalah judul dialog + /// [message] adalah pesan yang ditampilkan di dialog + /// [confirmText] adalah teks tombol konfirmasi + /// [cancelText] adalah teks tombol batal + /// [onConfirm] adalah fungsi yang dipanggil ketika tombol konfirmasi ditekan + /// [onCancel] adalah fungsi yang dipanggil ketika tombol batal ditekan + /// [isDanger] menentukan apakah dialog bersifat berbahaya (merah) + static Future show({ + required String title, + required String message, + String confirmText = 'Ya', + String cancelText = 'Batal', + VoidCallback? onConfirm, + VoidCallback? onCancel, + bool isDanger = false, + }) async { + return await Get.dialog( + AlertDialog( + title: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDanger ? AppColors.error : AppColors.textPrimary, + ), + ), + content: Text( + message, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + actions: [ + // Tombol batal + TextButton( + onPressed: () { + Get.back(result: false); + if (onCancel != null) onCancel(); + }, + child: Text( + cancelText, + style: TextStyle( + color: AppColors.textSecondary, + ), + ), + ), + + // Tombol konfirmasi + TextButton( + onPressed: () { + Get.back(result: true); + if (onConfirm != null) onConfirm(); + }, + child: Text( + confirmText, + style: TextStyle( + color: isDanger ? AppColors.error : AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + barrierDismissible: false, + ); + } + + /// Menampilkan dialog konfirmasi berbahaya + /// + /// Dialog ini memiliki warna merah untuk menandakan tindakan berbahaya. + static Future showDanger({ + required String title, + required String message, + String confirmText = 'Hapus', + String cancelText = 'Batal', + VoidCallback? onConfirm, + VoidCallback? onCancel, + }) async { + return await show( + title: title, + message: message, + confirmText: confirmText, + cancelText: cancelText, + onConfirm: onConfirm, + onCancel: onCancel, + isDanger: true, + ); + } +} diff --git a/lib/app/widgets/dialogs/detail_penitipan_dialog.dart b/lib/app/widgets/dialogs/detail_penitipan_dialog.dart new file mode 100644 index 0000000..94e9db2 --- /dev/null +++ b/lib/app/widgets/dialogs/detail_penitipan_dialog.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; +import 'package:penyaluran_app/app/utils/date_time_helper.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Dialog untuk menampilkan detail penitipan bantuan +/// +/// Dialog ini menampilkan informasi lengkap tentang penitipan bantuan. +class DetailPenitipanDialog { + /// Menampilkan dialog detail penitipan + /// + /// [context] adalah BuildContext + /// [item] adalah model penitipan bantuan + /// [donaturNama] adalah nama donatur + /// [kategoriNama] adalah nama kategori bantuan + /// [kategoriSatuan] adalah satuan kategori bantuan + /// [getPetugasDesaNama] adalah fungsi untuk mendapatkan nama petugas desa + /// [showFullScreenImage] adalah fungsi untuk menampilkan gambar layar penuh + static void show({ + required BuildContext context, + required PenitipanBantuanModel item, + required String donaturNama, + required String kategoriNama, + required String kategoriSatuan, + required String Function(String?) getPetugasDesaNama, + required Function(String) showFullScreenImage, + }) { + // Cek apakah penitipan berbentuk uang + final isUang = item.isUang ?? false; + + Get.dialog( + AlertDialog( + title: const Text('Detail Penitipan'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('ID', item.id ?? '-'), + _buildInfoRow('Donatur', donaturNama), + _buildInfoRow('Kategori', kategoriNama), + _buildInfoRow( + 'Jumlah', + isUang + ? 'Rp ${item.jumlah?.toStringAsFixed(0) ?? '0'}' + : '${item.jumlah?.toString() ?? '0'} $kategoriSatuan', + ), + _buildInfoRow( + 'Tanggal Penitipan', + DateTimeHelper.formatDateTime( + item.tanggalPenitipan ?? item.createdAt), + ), + _buildInfoRow( + 'Status', + item.status ?? 'Belum diproses', + ), + if (item.petugasDesaId != null) + _buildInfoRow( + 'Petugas Desa', + getPetugasDesaNama(item.petugasDesaId), + ), + if (item.tanggalVerifikasi != null) + _buildInfoRow( + 'Tanggal Verifikasi', + DateTimeHelper.formatDateTime(item.tanggalVerifikasi), + ), + if (item.deskripsi != null && item.deskripsi!.isNotEmpty) + _buildInfoRow('Deskripsi', item.deskripsi!), + + // Gambar bukti penitipan + if (item.fotoBantuan != null && item.fotoBantuan!.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text( + 'Bukti Penitipan', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () => showFullScreenImage(item.fotoBantuan!.first), + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(item.fotoBantuan!.first), + fit: BoxFit.cover, + ), + ), + ), + ), + ], + + // Bukti serah terima + if (item.fotoBuktiSerahTerima != null && + item.fotoBuktiSerahTerima!.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text( + 'Bukti Serah Terima', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () => showFullScreenImage(item.fotoBuktiSerahTerima!), + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(item.fotoBuktiSerahTerima!), + fit: BoxFit.cover, + ), + ), + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'Tutup', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + /// Menampilkan gambar dalam layar penuh + static void showFullScreenImage(BuildContext context, String imageUrl) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Container( + color: Colors.black, + child: Center( + child: InteractiveViewer( + panEnabled: true, + boundaryMargin: const EdgeInsets.all(20), + minScale: 0.5, + maxScale: 4, + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Text( + 'Gagal memuat gambar', + style: TextStyle(color: Colors.white), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); + } + + /// Membangun baris informasi + static Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ); + } +} diff --git a/lib/app/widgets/indicators/full_screen_loading.dart b/lib/app/widgets/indicators/full_screen_loading.dart new file mode 100644 index 0000000..4272ba3 --- /dev/null +++ b/lib/app/widgets/indicators/full_screen_loading.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/widgets/indicators/loading_indicator.dart'; + +/// Indikator loading yang menempati seluruh layar +/// +/// Indikator ini memiliki latar belakang semi-transparan dan dapat +/// dikonfigurasi dengan pesan. +class FullScreenLoading extends StatelessWidget { + /// Pesan yang ditampilkan di bawah indikator loading (opsional) + final String? message; + + /// Warna indikator loading + final Color? color; + + /// Warna latar belakang + final Color backgroundColor; + + /// Apakah dapat dibatalkan dengan mengetuk di luar + final bool dismissible; + + /// Konstruktor untuk FullScreenLoading + const FullScreenLoading({ + super.key, + this.message, + this.color, + this.backgroundColor = Colors.black54, + this.dismissible = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: backgroundColor, + child: LoadingIndicator( + message: message, + color: color ?? Colors.white, + textColor: Colors.white, + ), + ); + } + + /// Menampilkan indikator loading di seluruh layar + static void show(BuildContext context, + {String? message, bool dismissible = false}) { + showDialog( + context: context, + barrierDismissible: dismissible, + builder: (context) => FullScreenLoading( + message: message, + dismissible: dismissible, + ), + ); + } + + /// Menyembunyikan indikator loading + static void hide(BuildContext context) { + Navigator.of(context, rootNavigator: true).pop(); + } +} diff --git a/lib/app/widgets/loading_indicator.dart b/lib/app/widgets/indicators/loading_indicator.dart similarity index 55% rename from lib/app/widgets/loading_indicator.dart rename to lib/app/widgets/indicators/loading_indicator.dart index 4eee4c6..07cc652 100644 --- a/lib/app/widgets/loading_indicator.dart +++ b/lib/app/widgets/indicators/loading_indicator.dart @@ -1,16 +1,41 @@ import 'package:flutter/material.dart'; import 'package:penyaluran_app/app/theme/app_colors.dart'; +/// Indikator loading yang digunakan untuk menampilkan status loading +/// +/// Indikator ini dapat dikonfigurasi dengan pesan, warna, dan ukuran. class LoadingIndicator extends StatelessWidget { + /// Pesan yang ditampilkan di bawah indikator loading (opsional) final String? message; + + /// Warna indikator loading final Color? color; + + /// Ukuran indikator loading final double size; + /// Ketebalan garis indikator loading + final double strokeWidth; + + /// Warna teks pesan + final Color? textColor; + + /// Ukuran teks pesan + final double textSize; + + /// Jarak antara indikator loading dan pesan + final double spacing; + + /// Konstruktor untuk LoadingIndicator const LoadingIndicator({ super.key, this.message, this.color, this.size = 40.0, + this.strokeWidth = 3.0, + this.textColor, + this.textSize = 16.0, + this.spacing = 16.0, }); @override @@ -27,16 +52,16 @@ class LoadingIndicator extends StatelessWidget { valueColor: AlwaysStoppedAnimation( color ?? AppColors.primary, ), - strokeWidth: 3.0, + strokeWidth: strokeWidth, ), ), if (message != null) ...[ - const SizedBox(height: 16), + SizedBox(height: spacing), Text( message!, style: TextStyle( - fontSize: 16, - color: Colors.grey[700], + fontSize: textSize, + color: textColor ?? Colors.grey[700], ), textAlign: TextAlign.center, ), diff --git a/lib/app/widgets/indicators/status_pill.dart b/lib/app/widgets/indicators/status_pill.dart new file mode 100644 index 0000000..95f5a14 --- /dev/null +++ b/lib/app/widgets/indicators/status_pill.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; + +/// Indikator status berbentuk pill yang digunakan untuk menampilkan status +/// +/// Indikator ini memiliki teks status dan warna latar belakang yang dapat +/// dikonfigurasi. +class StatusPill extends StatelessWidget { + /// Teks status yang ditampilkan + final String status; + + /// Warna latar belakang pill + final Color? backgroundColor; + + /// Gaya teks status + final TextStyle? textStyle; + + /// Warna teks status + final Color? textColor; + + /// Padding pill + final EdgeInsetsGeometry padding; + + /// Radius border pill + final double borderRadius; + + /// Konstruktor untuk StatusPill + const StatusPill({ + super.key, + required this.status, + this.backgroundColor, + this.textStyle, + this.textColor, + this.padding = const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + this.borderRadius = 12, + }); + + /// Konstruktor factory untuk StatusPill dengan status "Terverifikasi" + factory StatusPill.verified({String status = 'Terverifikasi'}) { + return StatusPill( + status: status, + backgroundColor: AppTheme.verifiedColor, + textColor: Colors.white, + ); + } + + /// Konstruktor factory untuk StatusPill dengan status "Diproses" + factory StatusPill.processed({String status = 'Diproses'}) { + return StatusPill( + status: status, + backgroundColor: AppTheme.processedColor, + textColor: Colors.white, + ); + } + + /// Konstruktor factory untuk StatusPill dengan status "Ditolak" + factory StatusPill.rejected({String status = 'Ditolak'}) { + return StatusPill( + status: status, + backgroundColor: AppTheme.rejectedColor, + textColor: Colors.white, + ); + } + + /// Konstruktor factory untuk StatusPill dengan status "Dijadwalkan" + factory StatusPill.scheduled({String status = 'Dijadwalkan'}) { + return StatusPill( + status: status, + backgroundColor: AppTheme.scheduledColor, + textColor: Colors.white, + ); + } + + /// Konstruktor factory untuk StatusPill dengan status "Selesai" + factory StatusPill.completed({String status = 'Selesai'}) { + return StatusPill( + status: status, + backgroundColor: AppTheme.completedColor, + textColor: Colors.white, + ); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Container( + padding: padding, + decoration: BoxDecoration( + color: backgroundColor ?? AppTheme.verifiedColor, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Text( + status, + style: textStyle ?? + textTheme.bodySmall?.copyWith( + fontSize: 10, + color: textColor ?? Colors.white, + ), + ), + ); + } +} diff --git a/lib/app/widgets/inputs/dropdown_input.dart b/lib/app/widgets/inputs/dropdown_input.dart new file mode 100644 index 0000000..c53a2c1 --- /dev/null +++ b/lib/app/widgets/inputs/dropdown_input.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Item untuk dropdown +class DropdownItem { + /// Nilai item + final T value; + + /// Label yang ditampilkan + final String label; + + /// Konstruktor untuk DropdownItem + const DropdownItem({ + required this.value, + required this.label, + }); +} + +/// Input dropdown yang digunakan di seluruh aplikasi +/// +/// Input ini dapat dikonfigurasi untuk berbagai jenis dropdown dan validasi. +class DropdownInput extends StatelessWidget { + /// Label yang ditampilkan di atas input + final String label; + + /// Hint yang ditampilkan di dalam input + final String? hint; + + /// Daftar item dropdown + final List> items; + + /// Nilai yang dipilih + final T? value; + + /// Fungsi yang dipanggil ketika nilai dropdown berubah + final Function(T?)? onChanged; + + /// Apakah input dinonaktifkan + final bool enabled; + + /// Apakah input bersifat wajib + final bool required; + + /// Pesan kesalahan yang ditampilkan di bawah input + final String? errorText; + + /// Fungsi validasi input + final String? Function(T?)? validator; + + /// Konstruktor untuk DropdownInput + const DropdownInput({ + super.key, + required this.label, + this.hint, + required this.items, + this.value, + this.onChanged, + this.enabled = true, + this.required = false, + this.errorText, + this.validator, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + if (required) + Text( + ' *', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.error, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Dropdown + DropdownButtonFormField( + value: value, + onChanged: enabled ? onChanged : null, + validator: validator, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + style: TextStyle( + fontSize: 14, + color: enabled ? AppColors.textPrimary : AppColors.textSecondary, + ), + decoration: InputDecoration( + hintText: hint, + errorText: errorText, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.divider, + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.divider, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.primary, + width: 1.5, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.error, + width: 1, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.error, + width: 1.5, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.disabled, + width: 1, + ), + ), + ), + items: items.map((DropdownItem item) { + return DropdownMenuItem( + value: item.value, + child: Text(item.label), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/lib/app/widgets/inputs/text_input.dart b/lib/app/widgets/inputs/text_input.dart new file mode 100644 index 0000000..0d4321e --- /dev/null +++ b/lib/app/widgets/inputs/text_input.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:penyaluran_app/app/theme/app_colors.dart'; + +/// Input teks yang digunakan di seluruh aplikasi +/// +/// Input ini dapat dikonfigurasi untuk berbagai jenis input dan validasi. +class TextInput extends StatelessWidget { + /// Label yang ditampilkan di atas input + final String label; + + /// Hint yang ditampilkan di dalam input + final String? hint; + + /// Controller untuk input + final TextEditingController? controller; + + /// Fungsi yang dipanggil ketika nilai input berubah + final Function(String)? onChanged; + + /// Fungsi yang dipanggil ketika input selesai diedit + final Function(String)? onSubmitted; + + /// Apakah input dinonaktifkan + final bool enabled; + + /// Apakah input bersifat wajib + final bool required; + + /// Apakah input bersifat rahasia (password) + final bool obscureText; + + /// Pesan kesalahan yang ditampilkan di bawah input + final String? errorText; + + /// Ikon yang ditampilkan di sebelah kanan input + final IconData? suffixIcon; + + /// Fungsi yang dipanggil ketika ikon di sebelah kanan input ditekan + final VoidCallback? onSuffixIconPressed; + + /// Jenis keyboard yang digunakan + final TextInputType keyboardType; + + /// Daftar pemformatan input + final List? inputFormatters; + + /// Jumlah baris input (untuk input multiline) + final int? maxLines; + + /// Jumlah karakter maksimum + final int? maxLength; + + /// Apakah input otomatis mendapatkan fokus + final bool autofocus; + + /// Fokus node untuk input + final FocusNode? focusNode; + + /// Fungsi validasi input + final String? Function(String?)? validator; + + /// Konstruktor untuk TextInput + const TextInput({ + super.key, + required this.label, + this.hint, + this.controller, + this.onChanged, + this.onSubmitted, + this.enabled = true, + this.required = false, + this.obscureText = false, + this.errorText, + this.suffixIcon, + this.onSuffixIconPressed, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.maxLines = 1, + this.maxLength, + this.autofocus = false, + this.focusNode, + this.validator, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + if (required) + Text( + ' *', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.error, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Input + TextFormField( + controller: controller, + onChanged: onChanged, + onFieldSubmitted: onSubmitted, + enabled: enabled, + obscureText: obscureText, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + maxLength: maxLength, + autofocus: autofocus, + focusNode: focusNode, + validator: validator, + style: TextStyle( + fontSize: 14, + color: enabled ? AppColors.textPrimary : AppColors.textSecondary, + ), + decoration: InputDecoration( + hintText: hint, + errorText: errorText, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.divider, + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.divider, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.primary, + width: 1.5, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.error, + width: 1, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.error, + width: 1.5, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppColors.disabled, + width: 1, + ), + ), + suffixIcon: suffixIcon != null + ? IconButton( + icon: Icon(suffixIcon), + onPressed: onSuffixIconPressed, + ) + : null, + ), + ), + ], + ); + } +} diff --git a/lib/app/widgets/navigation_button.dart b/lib/app/widgets/navigation_button.dart deleted file mode 100644 index f60254c..0000000 --- a/lib/app/widgets/navigation_button.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:penyaluran_app/app/theme/app_theme.dart'; - -class NavigationButton extends StatelessWidget { - final String label; - final IconData? icon; - final Widget? iconWidget; - final VoidCallback onPressed; - - const NavigationButton({ - super.key, - required this.label, - this.icon, - this.iconWidget, - required this.onPressed, - }) : assert(icon != null || iconWidget != null, - 'Either icon or iconWidget must be provided'); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return ConstrainedBox( - constraints: BoxConstraints(minWidth: 70), // Set a minimum width - child: GestureDetector( - onTap: onPressed, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.primaryColor, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: - MainAxisSize.min, // Important for preventing layout issues - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: textTheme.bodySmall?.copyWith( - fontSize: 10, - fontWeight: FontWeight.w500, - color: Color(0xFFAFF8FF), - ), - ), - const SizedBox(width: 4), - if (icon != null) - Icon( - icon, - size: 12, - color: Color(0xFFAFF8FF), - ) - else if (iconWidget != null) - SizedBox( - width: 12, - height: 12, - child: iconWidget, - ), - ], - ), - ), - ), - ); - } -} - -// Data class for navigation button -class NavigationButtonData { - final String label; - final IconData? icon; - final Widget? iconWidget; - - NavigationButtonData({ - required this.label, - this.icon, - this.iconWidget, - }) : assert(icon != null || iconWidget != null, - 'Either icon or iconWidget must be provided'); -} diff --git a/lib/app/widgets/statistic_card.dart b/lib/app/widgets/statistic_card.dart deleted file mode 100644 index 931a9fa..0000000 --- a/lib/app/widgets/statistic_card.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; - -class StatisticCard extends StatelessWidget { - final String title; - final String count; - final String subtitle; - final double height; - - const StatisticCard({ - super.key, - required this.title, - required this.count, - required this.subtitle, - required this.height, - }); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Container( - width: double.infinity, - height: height, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: AppTheme.primaryGradient, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: textTheme.bodyMedium?.copyWith( - fontSize: 14, - color: Colors.white.withAlpha(204), // 0.8 * 255 ≈ 204 - ), - ), - const SizedBox(height: 4), - Text( - count, - style: textTheme.headlineSmall?.copyWith( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: textTheme.bodySmall?.copyWith( - fontSize: 12, - color: Colors.white, - ), - ), - ], - ), - ); - } -} diff --git a/lib/app/widgets/status_pill.dart b/lib/app/widgets/status_pill.dart deleted file mode 100644 index 26c50e9..0000000 --- a/lib/app/widgets/status_pill.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:penyaluran_app/app/theme/app_theme.dart'; - -class StatusPill extends StatelessWidget { - final String status; - final Color? backgroundColor; - final TextStyle? textStyle; - - const StatusPill({ - super.key, - required this.status, - this.backgroundColor, - this.textStyle, - }); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), - decoration: BoxDecoration( - color: backgroundColor ?? AppTheme.verifiedColor, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - status, - style: textStyle ?? - textTheme.bodySmall?.copyWith( - fontSize: 10, - ), - ), - ); - } -} diff --git a/lib/app/widgets/widgets.dart b/lib/app/widgets/widgets.dart new file mode 100644 index 0000000..3e73c29 --- /dev/null +++ b/lib/app/widgets/widgets.dart @@ -0,0 +1,27 @@ +// File ini berisi ekspor semua widget yang dapat digunakan kembali +// dalam aplikasi. Ini memudahkan pengimporan widget dengan +// menggunakan satu jalur impor. + +// Buttons +export 'buttons/primary_button.dart'; +export 'buttons/secondary_button.dart'; +export 'buttons/navigation_button.dart'; + +// Cards +export 'cards/statistic_card.dart'; +export 'cards/info_card.dart'; + +// Dialogs +export 'dialogs/detail_penitipan_dialog.dart'; +export 'dialogs/confirmation_dialog.dart'; + +// Indicators +export 'indicators/loading_indicator.dart'; +export 'indicators/status_pill.dart'; + +// Inputs +export 'inputs/text_input.dart'; +export 'inputs/dropdown_input.dart'; + +// App Bar +export 'custom_app_bar.dart';