first commit

This commit is contained in:
2026-04-29 12:53:22 +07:00
commit e6a30eddd3
394 changed files with 16408 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppAccordion extends StatefulWidget {
const AppAccordion({
required this.title,
required this.child,
this.description,
this.initialExpanded = false,
super.key,
});
final String title;
final String? description;
final Widget child;
final bool initialExpanded;
@override
State<AppAccordion> createState() => _AppAccordionState();
}
class _AppAccordionState extends State<AppAccordion> {
late bool expanded;
@override
void initState() {
super.initState();
expanded = widget.initialExpanded;
}
void toggle() {
setState(() {
expanded = !expanded;
});
}
@override
Widget build(BuildContext context) {
return Container(
margin: AppPadding.b12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.neutral200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 3,
),
],
),
child: Column(
children: [
InkWell(
onTap: toggle,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: AppTextStyles.label.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: AppDurations.fast,
child: Icon(
Icons.keyboard_arrow_down,
color: AppColors.neutral400,
),
),
],
),
),
),
ClipRect(
child: AnimatedAlign(
alignment: Alignment.topCenter,
heightFactor: expanded ? 1 : 0,
duration: AppDurations.fast,
curve: Curves.easeInOut,
child: AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: AppDurations.fast,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.description != null) ...[
Text(
widget.description!,
style: AppTextStyles.small.copyWith(
fontStyle: FontStyle.italic,
color: AppColors.neutral700,
),
),
AppSpacing.h8,
],
widget.child,
],
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
class AppBottomNav extends StatelessWidget {
const AppBottomNav({
required this.currentIndex,
required this.onTap,
super.key,
});
final int currentIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
top: AppSizes.s12,
bottom: MediaQuery.of(context).viewPadding.bottom,
),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.neutral200),
),
color: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NavItem(
0,
currentIndex,
IconsaxPlusLinear.home_2,
IconsaxPlusBold.home_2,
'Beranda',
onTap,
),
_NavItem(
1,
currentIndex,
IconsaxPlusLinear.document,
IconsaxPlusBold.document,
'Berkas',
onTap,
),
_NavItem(
2,
currentIndex,
IconsaxPlusLinear.setting,
IconsaxPlusBold.setting,
'Pengaturan',
onTap,
),
],
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem(
this.index,
this.currentIndex,
this.icon,
this.activeIcon,
this.label,
this.onTap,
);
final int index;
final int currentIndex;
final IconData icon;
final IconData activeIcon;
final String label;
final ValueChanged<int> onTap;
bool get isActive => index == currentIndex;
@override
Widget build(BuildContext context) {
final activeColor = AppColors.primary900;
final inactiveColor = AppColors.neutral500;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(index),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isActive ? activeIcon : icon,
size: AppIconSizes.lg,
color: AppColors.primary900,
),
AppSpacing.h4,
Text(
label,
style: AppTextStyles.badge.copyWith(
fontSize: 11,
color: isActive ? activeColor : inactiveColor,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/appbar_type_enum.dart';
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
super.key,
this.type = AppButtonType.primary,
this.width,
this.height,
this.isExpanded = false,
this.radius = AppSizes.s8,
this.borderWidth = 1.5,
this.paddingVertical = 14,
this.paddingHorizontal = 24,
this.backgroundColor,
this.borderColor,
this.textColor,
this.fontSize = 16,
this.fontWeight = FontWeight.w600,
this.isLoading = false,
this.customContent,
});
final String label;
final VoidCallback? onPressed;
final AppButtonType type;
final double? width;
final double? height;
final bool isExpanded;
final double radius;
final double borderWidth;
final double paddingVertical;
final double paddingHorizontal;
final Color? backgroundColor;
final Color? borderColor;
final Color? textColor;
final double fontSize;
final FontWeight fontWeight;
final bool isLoading;
final Widget? customContent;
bool get _isPrimary => type == AppButtonType.primary;
@override
Widget build(BuildContext context) {
final bgColor = _isPrimary
? (backgroundColor ?? AppColors.primary900)
: (backgroundColor ?? AppColors.neutralWhite);
final brColor = _isPrimary
? Colors.transparent
: (borderColor ?? AppColors.primary900);
final txtColor = _isPrimary
? (textColor ?? Colors.white)
: (textColor ?? AppColors.primary900);
final defaultChild = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isLoading ? AppIconSizes.sm : 0,
margin: EdgeInsets.only(
right: isLoading ? AppSizes.s12 : 0,
),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isLoading ? 1 : 0,
child: SizedBox(
width: AppIconSizes.sm,
height: AppIconSizes.sm,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(txtColor),
),
),
),
),
Text(
label,
style: AppTextStyles.button.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: txtColor,
),
),
],
);
final button = ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: bgColor,
padding: EdgeInsets.symmetric(
vertical: paddingVertical,
horizontal: paddingHorizontal,
),
shape: RoundedRectangleBorder(
side: BorderSide(
color: brColor,
width: borderWidth,
),
borderRadius: BorderRadius.circular(radius),
),
),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: customContent ?? defaultChild,
),
);
return SizedBox(
width: isExpanded ? double.infinity : width,
height: height,
child: button,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:animated_custom_dropdown/custom_dropdown.dart';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppDropdown<T> extends StatelessWidget {
const AppDropdown({
required this.items,
required this.onChanged,
super.key,
this.value,
this.title,
this.hint = 'Select',
this.showTitle = true,
this.isRequired = false,
this.isValid = true,
this.informationValid = '',
this.bgColor = const Color(0xFFF9FAFB),
this.borderColor = const Color(0xFFE5E7EB),
this.radius = AppSizes.s16,
});
final List<T> items;
final T? value;
final ValueChanged<T?> onChanged;
final String? title;
final String hint;
final bool showTitle;
final bool isRequired;
final bool isValid;
final String informationValid;
final Color bgColor;
final Color borderColor;
final double radius;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTitle && title != null)
Padding(
padding: const EdgeInsets.only(bottom: AppSizes.s12),
child: Text(
title!,
style: AppTextStyles.label,
),
),
CustomDropdown<T>(
items: items,
initialItem: value,
onChanged: onChanged,
hintText: hint,
overlayHeight: 240,
decoration: CustomDropdownDecoration(
closedBorderRadius: BorderRadius.circular(radius),
closedFillColor: bgColor,
hintStyle: AppTextStyles.body,
),
),
],
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_spacing.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_text_styles.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
Widget empty() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
IconsaxPlusLinear.task_square,
size: 42,
color: AppColors.neutral400,
),
AppSpacing.h12,
Text(
'Oops,',
style: AppTextStyles.label,
),
Text(
'No Data Found',
style: AppTextStyles.small.copyWith(
color: AppColors.neutral600,
),
),
],
);
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class AppShadInput extends StatefulWidget {
const AppShadInput({
required this.title,
required this.hint,
super.key,
this.showTitle = true,
this.isRequired = false,
this.isEnabled = true,
this.width,
this.isPassword = false,
this.maxLines = 1,
this.maxLength,
this.keyboardType,
this.inputFormatters,
this.onChanged,
this.leading,
this.trailing,
this.focusNode,
this.textInputAction,
this.autofocus = false,
this.readOnly = false,
this.padding,
this.inputPadding = const EdgeInsets.symmetric(vertical: 7, horizontal: 5),
this.bgColor = Colors.white,
this.decoration,
this.controller,
this.isForm = false,
this.id,
this.validator,
this.description,
this.radius,
});
final String title;
final String hint;
final bool showTitle;
final bool isRequired;
final bool isEnabled;
final double? width;
final double? radius;
final bool isPassword;
final int maxLines;
final int? maxLength;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final void Function(String)? onChanged;
final Widget? leading;
final Widget? trailing;
final FocusNode? focusNode;
final TextInputAction? textInputAction;
final bool autofocus;
final bool readOnly;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry inputPadding;
final Color bgColor;
final ShadDecoration? decoration;
final TextEditingController? controller;
final bool isForm;
final String? id;
final String? Function(String value)? validator;
final Widget? description;
@override
State<AppShadInput> createState() => _AppShadInputState();
}
class _AppShadInputState extends State<AppShadInput> {
bool _obscure = true;
@override
void initState() {
super.initState();
assert(
widget.isForm ? widget.id != null : widget.controller != null,
'Jika isForm=true maka id wajib diisi, jika isForm=false maka controller wajib diisi',
);
}
ShadDecoration get _decoration =>
widget.decoration ??
ShadDecoration(
color: widget.bgColor,
border: ShadBorder.all(
color: AppColors.neutral200,
radius: BorderRadius.circular(widget.radius ?? AppSizes.s12),
),
secondaryFocusedBorder: ShadBorder.all(
color: AppColors.neutral700,
width: 2,
radius: BorderRadius.circular(
(widget.radius ?? AppSizes.s12) + 4,
),
),
);
@override
Widget build(BuildContext context) {
final trailing = widget.isPassword
? ShadIconButton(
width: 24,
height: 24,
backgroundColor: AppColors.neutral200,
pressedBackgroundColor: AppColors.neutral200,
icon: Icon(
_obscure ? LucideIcons.eyeOff : LucideIcons.eye,
size: 18,
color: AppColors.neutral600,
),
onPressed: () {
setState(() => _obscure = !_obscure);
},
)
: widget.trailing;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle)
Padding(
padding: const EdgeInsets.only(bottom: AppSizes.s12),
child: Row(
children: [
Text(
widget.title,
style: AppTextStyles.label.copyWith(
color: AppColors.supportSteel600,
),
),
if (widget.isRequired)
const Text(
' *',
style: TextStyle(
fontSize: 16,
color: Colors.red,
),
),
],
),
),
SizedBox(
width: widget.width,
child: widget.isForm
? ShadInputFormField(
id: widget.id,
enabled: widget.isEnabled,
validator: widget.validator,
description: widget.description,
placeholder: Text(
widget.hint,
style: AppTextStyles.body.copyWith(
color: AppColors.neutral900.withOpacity(0.5),
),
),
decoration: _decoration,
obscureText: widget.isPassword && _obscure,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
onChanged: widget.onChanged,
leading: widget.leading,
onPressedOutside: (event) => FocusScope.of(context).unfocus(),
trailing: trailing,
inputPadding: widget.inputPadding,
style: AppTextStyles.body,
)
: ShadInput(
controller: widget.controller,
enabled: widget.isEnabled,
focusNode: widget.focusNode,
readOnly: widget.readOnly,
autofocus: widget.autofocus,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
inputFormatters: widget.inputFormatters,
obscureText: widget.isPassword && _obscure,
onChanged: widget.onChanged,
padding: widget.padding,
inputPadding: widget.inputPadding,
onPressedOutside: (event) => FocusScope.of(context).unfocus(),
decoration: _decoration,
placeholder: Text(
widget.hint,
style: AppTextStyles.body.copyWith(
color: AppColors.neutral900.withOpacity(0.5),
),
),
style: AppTextStyles.body,
leading: widget.leading,
trailing: trailing,
),
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_spacing.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
Widget somethingWrong() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
IconsaxPlusLinear.cloud_remove,
size: 52,
color: AppColors.neutral400,
),
AppSpacing.h12,
Text(
'Oops, something wrong',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
),
),
AppSpacing.h4,
Text(
'Please try again later',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.neutral600,
),
),
AppSpacing.h12,
],
);
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppTextField extends StatefulWidget {
const AppTextField({
required this.title,
required this.hint,
required this.password,
required this.controller,
required this.isValid,
required this.informationValid,
super.key,
this.maxLine = 1,
this.maxLength,
this.showTitle = true,
this.isEnabled = true,
this.keyboardType,
this.radius = AppSizes.s16,
this.phoneText,
this.inputFormatters,
this.width,
this.isRequired = false,
this.contentPaddingVertical = AppSizes.s12,
this.prefixIcon,
this.showPrefixIcon = false,
this.prefixIconColor,
this.prefixIconSize = AppIconSizes.md,
this.prefixIconPadding = AppSizes.s12,
this.isDense = false,
this.minHeight,
this.onChanged,
this.iconRight,
this.bgColor = const Color(0xFFF9FAFB),
this.hintColor = const Color(0x800A0A0A),
this.titleColor = const Color(0xFF364153),
this.borderColor = const Color(0xFFE5E7EB),
});
final String title;
final String hint;
final bool password;
final TextEditingController controller;
final int maxLine;
final int? maxLength;
final double contentPaddingVertical;
final bool isEnabled;
final bool showTitle;
final bool isRequired;
final bool isValid;
final String informationValid;
final double radius;
final double? width;
final String? phoneText;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final Color bgColor;
final Color titleColor;
final Color hintColor;
final Color borderColor;
final Widget? prefixIcon;
final bool showPrefixIcon;
final Color? prefixIconColor;
final double prefixIconSize;
final double prefixIconPadding;
final bool isDense;
final double? minHeight;
final Widget? iconRight;
final void Function(String)? onChanged;
@override
State<AppTextField> createState() => _AppTextFieldState();
}
class _AppTextFieldState extends State<AppTextField> {
bool _showPass = true;
final FocusNode _focusNode = FocusNode();
final GlobalKey _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
void _scrollIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
@override
void initState() {
super.initState();
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_addTapOutsideOverlay();
_scrollIntoView();
} else {
_removeTapOutsideOverlay();
}
});
}
void _addTapOutsideOverlay() {
_overlayEntry = OverlayEntry(
builder: (_) => Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _focusNode.unfocus,
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
void _removeTapOutsideOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
@override
void dispose() {
_removeTapOutsideOverlay();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
key: _fieldKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle)
Padding(
padding: AppPadding.b8,
child: Row(
children: [
Text(
widget.title,
style: AppTextStyles.label.copyWith(
color: widget.titleColor,
fontWeight: FontWeight.w400,
),
),
if (widget.isRequired)
Text(
' *',
style: AppTextStyles.body.copyWith(
color: AppColors.primary500,
fontWeight: FontWeight.w600,
),
),
],
),
),
Container(
width: widget.width,
constraints: widget.minHeight != null
? BoxConstraints(minHeight: widget.minHeight!)
: null,
decoration: BoxDecoration(
color: widget.bgColor,
borderRadius: BorderRadius.circular(widget.radius),
border: Border.all(
color: widget.isValid ? widget.borderColor : AppColors.primary500,
width: 1.2,
),
),
child: Row(
children: [
if (widget.showPrefixIcon && widget.prefixIcon != null)
Padding(
padding: EdgeInsets.only(
left: widget.prefixIconPadding,
right: AppSizes.s12,
),
child: IconTheme(
data: IconThemeData(
size: widget.prefixIconSize,
color: widget.prefixIconColor ?? widget.hintColor,
),
child: widget.prefixIcon!,
),
)
else
AppSpacing.w16,
if (widget.phoneText != null)
Padding(
padding: AppPadding.r8,
child: Text(
widget.phoneText!,
style: AppTextStyles.body.copyWith(
color: widget.hintColor,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: TextFormField(
controller: widget.controller,
obscureText: widget.password && _showPass,
maxLines: widget.maxLine,
enabled: widget.isEnabled,
maxLength: widget.maxLength,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
style: AppTextStyles.body.copyWith(
fontSize: widget.isDense ? 13 : 14,
),
decoration: InputDecoration(
isDense: widget.isDense,
hintText: widget.hint,
hintStyle: AppTextStyles.body.copyWith(
fontSize: widget.isDense ? 12 : 13,
color: widget.hintColor,
),
contentPadding: EdgeInsets.symmetric(
vertical: widget.isDense
? AppSizes.s8
: widget.contentPaddingVertical,
),
border: InputBorder.none,
counterText: '',
),
onChanged: widget.onChanged,
),
),
if (widget.iconRight != null)
Padding(
padding: AppPadding.r12,
child: widget.iconRight,
),
if (widget.password)
Padding(
padding: AppPadding.r12,
child: GestureDetector(
onTap: () => setState(() => _showPass = !_showPass),
child: Icon(
_showPass
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
size: AppIconSizes.md,
color: AppColors.neutral400,
),
),
),
],
),
),
if (!widget.isValid)
Padding(
padding: const EdgeInsets.only(
top: AppSizes.s8,
left: AppSizes.s12,
),
child: Text(
widget.informationValid,
style: AppTextStyles.small.copyWith(
color: AppColors.primary500,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/appbar_type_enum.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_button.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/appbottomsheet_controller.dart';
class AppBottomSheet extends StatefulWidget {
const AppBottomSheet({
required this.controller,
super.key,
this.icon,
this.title,
this.subTitle,
this.content,
this.primaryText,
this.secondaryText,
this.onPrimaryPressed,
this.onSecondaryPressed,
this.usePrimaryButton = true,
this.useSecondaryButton = false,
this.onOpened,
this.onClosed,
this.onDismissed,
this.enableDrag = true,
});
final AppBottomSheetController controller;
final String? icon;
final String? title;
final String? subTitle;
final Widget? content;
final String? primaryText;
final String? secondaryText;
final VoidCallback? onPrimaryPressed;
final VoidCallback? onSecondaryPressed;
final bool useSecondaryButton;
final bool usePrimaryButton;
final VoidCallback? onOpened;
final VoidCallback? onClosed;
final VoidCallback? onDismissed;
final bool enableDrag;
@override
State<AppBottomSheet> createState() => _AppBottomSheetState();
}
class _AppBottomSheetState extends State<AppBottomSheet> {
bool _dismissedBySystem = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOpened?.call();
});
}
@override
void dispose() {
if (_dismissedBySystem) {
widget.onDismissed?.call();
}
widget.onClosed?.call();
super.dispose();
}
void _closeByAction() {
_dismissedBySystem = false;
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: AnimatedBuilder(
animation: widget.controller,
builder: (_, _) {
return Align(
alignment: Alignment.bottomCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppSizes.maxContentWidth,
),
child: Container(
padding: EdgeInsets.fromLTRB(
AppSizes.s16,
AppSizes.s16,
AppSizes.s16,
MediaQuery.of(context).viewPadding.bottom + AppSizes.s16,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: AppSizes.bottomSheetHandleWidth,
height: AppSizes.bottomSheetHandleHeight,
margin: const EdgeInsets.only(
bottom: AppSizes.s16,
),
decoration: BoxDecoration(
color: AppColors.neutral200,
borderRadius: BorderRadius.circular(AppSizes.s8),
),
),
),
if (widget.icon != null) ...[
Center(
child: widget.icon!.endsWith('.svg')
? SvgPicture.asset(
widget.icon!,
width:
MediaQuery.of(context).size.width *
AppSizes.illustrationWidthFactor,
)
: Image.asset(
widget.icon!,
width:
MediaQuery.of(context).size.width *
AppSizes.illustrationWidthFactor,
),
),
AppSpacing.h16,
],
if (widget.title != null)
Text(
widget.title!,
style: AppTextStyles.h3,
),
if (widget.subTitle != null) ...[
AppSpacing.h8,
Text(
widget.subTitle!,
style: AppTextStyles.small,
),
],
if (widget.content != null) ...[
AppSpacing.h24,
widget.content!,
],
if (widget.useSecondaryButton || widget.usePrimaryButton)
AppSpacing.h24,
Row(
children: [
if (widget.useSecondaryButton) ...[
Expanded(
child: AppButton(
radius: AppSizes.s12,
paddingVertical: AppSizes.s12,
backgroundColor: Colors.white,
label: widget.secondaryText ?? 'Batal',
type: AppButtonType.secondary,
onPressed: widget.controller.isLoading
? null
: () {
_closeByAction();
widget.onSecondaryPressed?.call();
},
),
),
AppSpacing.w12,
],
if (widget.usePrimaryButton)
Expanded(
child: AppButton(
label: widget.primaryText ?? 'OK',
radius: AppSizes.s12,
paddingVertical: AppSizes.s12,
isLoading: widget.controller.isLoading,
onPressed: widget.controller.isLoading
? null
: () {
widget.onPrimaryPressed?.call();
},
),
),
],
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
class AppBottomSheetController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
void setLoading({required bool value}) {
if (_isLoading == value) return;
_isLoading = value;
notifyListeners();
}
}

View File

@@ -0,0 +1,289 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
typedef AppAsyncCallback = Future<void> Function();
class AppScreen extends StatelessWidget {
const AppScreen({
required this.body,
super.key,
this.isSliver = false,
this.slivers,
this.appBar,
this.title,
this.centerTitle = true,
this.leading,
this.actions,
this.onBack,
this.useWillPopScope = false,
this.useShadowAppBar = true,
this.scrollableBody = true,
this.scrollController,
this.physics,
this.refreshController,
this.enablePullDown = false,
this.enablePullUp = false,
this.onRefresh,
this.onLoadMore,
this.refreshHeader,
this.refreshFooter,
this.footers,
this.footersPadding = AppPadding.p16,
this.hasSafeArea = true,
this.safeTop = true,
this.safeBottom,
this.safeLeft = true,
this.safeRight = true,
this.backgroundColor,
this.backgroundWidgets,
this.resizeToAvoidBottomInset,
this.floatingActionButton,
this.bottomNavigationBar,
this.extendBody = false,
this.extendBodyBehindAppBar = false,
this.statusBarStyle,
this.isLoading = false,
});
final bool isLoading;
final Widget body;
final bool isSliver;
final List<Widget>? slivers;
final PreferredSizeWidget? appBar;
final bool useShadowAppBar;
final String? title;
final bool centerTitle;
final Widget? leading;
final List<Widget>? actions;
final VoidCallback? onBack;
final bool useWillPopScope;
final bool scrollableBody;
final ScrollController? scrollController;
final ScrollPhysics? physics;
final RefreshController? refreshController;
final bool enablePullDown;
final bool enablePullUp;
final AppAsyncCallback? onRefresh;
final AppAsyncCallback? onLoadMore;
final Widget? refreshHeader;
final Widget? refreshFooter;
final List<Widget>? footers;
final EdgeInsets footersPadding;
final bool hasSafeArea;
final bool safeTop;
final bool? safeBottom;
final bool safeLeft;
final bool safeRight;
final Color? backgroundColor;
final List<Widget>? backgroundWidgets;
final bool? resizeToAvoidBottomInset;
final Widget? floatingActionButton;
final Widget? bottomNavigationBar;
final bool extendBody;
final bool extendBodyBehindAppBar;
final SystemUiOverlayStyle? statusBarStyle;
Widget _wrapScrollbar(BuildContext context, Widget child) {
return ScrollbarTheme(
data: ScrollbarThemeData(
thumbColor: MaterialStateProperty.all(AppColors.neutral400),
thickness: MaterialStateProperty.all(3),
radius: const Radius.circular(AppSizes.s24),
),
child: Scrollbar(
controller: scrollController,
interactive: true,
thumbVisibility:
MediaQuery.of(context).size.width > AppSizes.maxContentWidth,
child: child,
),
);
}
Widget _buildBodyContent(BuildContext context) {
Widget content;
if (isSliver) {
content = _wrapScrollbar(
context,
CustomScrollView(
controller: scrollController,
physics: physics,
slivers: slivers!,
),
);
} else {
content = body;
if (scrollableBody) {
content = _wrapScrollbar(
context,
SingleChildScrollView(
controller: scrollController,
physics: physics,
child: content,
),
);
}
}
if (refreshController != null) {
content = SmartRefresher(
controller: refreshController!,
enablePullDown: enablePullDown,
enablePullUp: enablePullUp,
onRefresh: onRefresh,
onLoading: onLoadMore,
header: refreshHeader,
footer: refreshFooter,
physics: physics,
child: content,
);
}
if (footers == null) return content;
return Column(
children: [
Expanded(child: content),
Padding(
padding: footersPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: footers!,
),
),
],
);
}
Widget _buildBody(BuildContext context) {
final content = backgroundWidgets == null
? _buildBodyContent(context)
: Stack(
children: [
...backgroundWidgets!,
_buildBodyContent(context),
],
);
final safeContent = hasSafeArea
? SafeArea(
top: safeTop,
left: safeLeft,
right: safeRight,
bottom:
safeBottom ??
(!kIsWeb && defaultTargetPlatform == TargetPlatform.android),
child: content,
)
: content;
return Stack(
children: [
safeContent,
_buildLoadingOverlay(),
],
);
}
Widget _buildLoadingOverlay() {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isLoading ? 1 : 0,
child: isLoading
? Positioned.fill(
child: ColoredBox(
color: Colors.black.withOpacity(0.1),
child: Center(
child: Container(
width: 70,
height: 70,
padding: AppPadding.p16,
decoration: BoxDecoration(
color: AppColors.neutralWhite,
borderRadius: AppRadius.r10,
),
child: CircularProgressIndicator(
color: AppColors.primary900,
strokeWidth: 3,
backgroundColor: Colors.transparent,
),
),
),
),
)
: const SizedBox(),
);
}
PreferredSizeWidget? _buildAppBar(BuildContext context) {
if (appBar != null) return appBar;
if (title == null) return null;
return AppBar(
backgroundColor: AppColors.neutralWhite,
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: useShadowAppBar ? 4 : 0,
shadowColor: useShadowAppBar
? Colors.black.withOpacity(AppOpacity.opacity15)
: Colors.transparent,
title: Text(
title!,
style: AppTextStyles.h3,
),
centerTitle: centerTitle,
leading:
leading ??
(onBack != null
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
)
: null),
actions: actions,
);
}
@override
Widget build(BuildContext context) {
final Widget screen = AnnotatedRegion<SystemUiOverlayStyle>(
value:
statusBarStyle ??
SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: _buildAppBar(context),
body: _buildBody(context),
floatingActionButton: floatingActionButton,
bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody,
extendBodyBehindAppBar: extendBodyBehindAppBar,
),
);
if (!useWillPopScope) return screen;
return WillPopScope(
onWillPop: () async {
onBack?.call();
return false;
},
child: screen,
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/layout/app_screen.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class PdfViewerPage extends StatelessWidget {
const PdfViewerPage({required this.url, super.key});
final String url;
@override
Widget build(BuildContext context) {
return AppScreen(
title: 'Preview File',
backgroundColor: AppColors.neutralWhite,
scrollableBody: false,
body: SfPdfViewer.network(
url,
),
);
}
}