2026-03-01 19:11:08 +07:00

495 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../../common/theme/theme.dart';
import '../spaces/space.dart';
/// Hasil selection, sebagai pengganti [DateRangePickerSelectionChangedArgs]
/// dari Syncfusion. Gunakan class ini di pemanggil.
class DateRangeSelection {
final DateTime? startDate;
final DateTime? endDate;
const DateRangeSelection({this.startDate, this.endDate});
bool get isValid => startDate != null && endDate != null;
}
class DateRangePickerModal {
static Future<DateRangeSelection?> show({
required BuildContext context,
String title = 'Pilih Rentang Tanggal',
DateTime? initialStartDate,
DateTime? initialEndDate,
DateTime? minDate,
DateTime? maxDate,
String confirmText = 'Pilih',
String cancelText = 'Batal',
Color primaryColor = AppColor.primary,
Function(DateTime? startDate, DateTime? endDate)? onChanged,
}) async {
return await showDialog<DateRangeSelection?>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) => _DateRangePickerDialog(
title: title,
initialStartDate: initialStartDate,
initialEndDate: initialEndDate,
minDate: minDate,
maxDate: maxDate,
confirmText: confirmText,
cancelText: cancelText,
primaryColor: primaryColor,
onChanged: onChanged,
),
);
}
}
class _DateRangePickerDialog extends StatefulWidget {
final String title;
final DateTime? initialStartDate;
final DateTime? initialEndDate;
final DateTime? minDate;
final DateTime? maxDate;
final String confirmText;
final String cancelText;
final Color primaryColor;
final Function(DateTime? startDate, DateTime? endDate)? onChanged;
const _DateRangePickerDialog({
required this.title,
this.initialStartDate,
this.initialEndDate,
this.minDate,
this.maxDate,
required this.confirmText,
required this.cancelText,
required this.primaryColor,
this.onChanged,
});
@override
State<_DateRangePickerDialog> createState() => _DateRangePickerDialogState();
}
class _DateRangePickerDialogState extends State<_DateRangePickerDialog>
with TickerProviderStateMixin {
DateTime _focusedDay = DateTime.now();
DateTime? _rangeStart;
DateTime? _rangeEnd;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
// Restore initial range jika ada
_rangeStart = widget.initialStartDate;
_rangeEnd = widget.initialEndDate;
_focusedDay = widget.initialStartDate ?? DateTime.now();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// Manual range selection logic:
/// - Tap pertama → set rangeStart, clear rangeEnd
/// - Tap kedua → set rangeEnd (jika setelah start), atau reset start
void _onDaySelected(DateTime selectedDay, DateTime focusedDay) {
setState(() {
_focusedDay = focusedDay;
if (_rangeStart == null || (_rangeStart != null && _rangeEnd != null)) {
// Mulai range baru
_rangeStart = selectedDay;
_rangeEnd = null;
} else {
// Sudah ada start, tentukan end
if (selectedDay.isBefore(_rangeStart!)) {
// Jika pilih tanggal sebelum start → jadikan start baru
_rangeStart = selectedDay;
_rangeEnd = null;
} else if (isSameDay(selectedDay, _rangeStart)) {
// Tap hari yang sama → reset
_rangeStart = null;
_rangeEnd = null;
} else {
_rangeEnd = selectedDay;
}
}
});
}
String _formatDate(DateTime date) {
final months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
String get _selectionText {
if (_rangeStart != null && _rangeEnd != null) {
return '${_formatDate(_rangeStart!)} - ${_formatDate(_rangeEnd!)}';
} else if (_rangeStart != null) {
return '${_formatDate(_rangeStart!)} - Pilih tanggal akhir';
}
return 'Belum ada tanggal dipilih';
}
bool get _isValidSelection => _rangeStart != null && _rangeEnd != null;
/// Apakah hari ada di dalam range (eksklusif start & end)
// bool _isInRange(DateTime day) {
// if (_rangeStart == null || _rangeEnd == null) return false;
// return day.isAfter(_rangeStart!) && day.isBefore(_rangeEnd!);
// }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
child: Container(
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxWidth: 400,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Header ──────────────────────────────────────────
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
widget.primaryColor,
widget.primaryColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
const Icon(
Icons.calendar_today_rounded,
color: Colors.white,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// ── Scrollable Content ───────────────────────────────
Flexible(
child: SingleChildScrollView(
child: Column(
children: [
SpaceHeight(16),
// ── TableCalendar ────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.withOpacity(0.2),
),
),
child: TableCalendar(
firstDay:
widget.minDate ??
DateTime.utc(2000, 1, 1),
lastDay:
widget.maxDate ??
DateTime.utc(2100, 12, 31),
focusedDay: _focusedDay,
rangeStartDay: _rangeStart,
rangeEndDay: _rangeEnd,
rangeSelectionMode:
RangeSelectionMode.toggledOn,
onDaySelected: _onDaySelected,
onRangeSelected: (start, end, focusedDay) {
// table_calendar juga emit ini saat range mode,
// kita tangani di onDaySelected saja.
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
selectedDayPredicate: (day) => false,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
leftChevronIcon: Icon(
Icons.chevron_left_rounded,
color: widget.primaryColor,
),
rightChevronIcon: Icon(
Icons.chevron_right_rounded,
color: widget.primaryColor,
),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.primaryColor,
),
weekendStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.primaryColor.withOpacity(
0.6,
),
),
),
calendarStyle: CalendarStyle(
// Today
todayDecoration: BoxDecoration(
border: Border.all(
color: widget.primaryColor,
width: 1.5,
),
shape: BoxShape.circle,
),
todayTextStyle: TextStyle(
color: widget.primaryColor,
fontWeight: FontWeight.bold,
),
// Range start
rangeStartDecoration: BoxDecoration(
color: widget.primaryColor,
shape: BoxShape.circle,
),
rangeStartTextStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
// Range end
rangeEndDecoration: BoxDecoration(
color: widget.primaryColor,
shape: BoxShape.circle,
),
rangeEndTextStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
// Within range
withinRangeDecoration: BoxDecoration(
color: widget.primaryColor.withOpacity(
0.15,
),
shape: BoxShape.rectangle,
),
withinRangeTextStyle: TextStyle(
color: widget.primaryColor,
fontWeight: FontWeight.w500,
fontSize: 14,
),
// Outside
outsideDaysVisible: false,
),
),
),
),
// ── Selection Info ───────────────────────────
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: widget.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.primaryColor.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tanggal Terpilih:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: widget.primaryColor,
),
),
const SizedBox(height: 4),
Text(
_selectionText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
],
),
),
),
// ── Action Buttons ───────────────────────────────────
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 14,
),
side: BorderSide(color: Colors.grey.shade400),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
widget.cancelText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _isValidSelection
? () {
widget.onChanged?.call(
_rangeStart,
_rangeEnd,
);
Navigator.of(context).pop(
DateRangeSelection(
startDate: _rangeStart,
endDate: _rangeEnd,
),
);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: widget.primaryColor,
padding: const EdgeInsets.symmetric(
vertical: 14,
),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
disabledBackgroundColor: Colors.grey.shade300,
),
child: Text(
widget.confirmText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _isValidSelection
? Colors.white
: Colors.grey.shade600,
),
),
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}