1014 lines
35 KiB
Dart
1014 lines
35 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
|
|
class AddScheduleDialog extends StatefulWidget {
|
|
final Function(Map<String, dynamic>)? onScheduleAdded;
|
|
final List<Map<String, dynamic>> existingSchedules;
|
|
final DateTime? initialStartDate;
|
|
final Map<String, dynamic>? scheduleToEdit;
|
|
|
|
const AddScheduleDialog({
|
|
super.key,
|
|
this.onScheduleAdded,
|
|
required this.existingSchedules,
|
|
this.initialStartDate,
|
|
this.scheduleToEdit,
|
|
});
|
|
|
|
@override
|
|
State<AddScheduleDialog> createState() => _AddScheduleDialogState();
|
|
}
|
|
|
|
class _AddScheduleDialogState extends State<AddScheduleDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
// Track validation state for each field
|
|
bool _seedCostValid = false;
|
|
bool _fertilizerCostValid = false;
|
|
bool _pesticideCostValid = false;
|
|
bool _irrigationCostValid = false;
|
|
bool _expectedYieldValid = false;
|
|
final _cropNameController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
final _seedCostController = TextEditingController();
|
|
final _fertilizerCostController = TextEditingController();
|
|
final _pesticideCostController = TextEditingController();
|
|
final _irrigationCostController = TextEditingController();
|
|
final _expectedYieldController = TextEditingController();
|
|
|
|
// Tambahkan ScrollController untuk auto-scroll
|
|
final _scrollController = ScrollController();
|
|
|
|
DateTime _startDate = DateTime.now();
|
|
DateTime _endDate = DateTime.now().add(const Duration(days: 90));
|
|
String? _selectedFieldId;
|
|
int? _selectedPlot;
|
|
Map<String, dynamic>? _selectedFieldData;
|
|
|
|
List<Map<String, dynamic>> _fields = [];
|
|
bool _isLoading = false;
|
|
bool _isLoadingFields = true;
|
|
bool _isSaved = false;
|
|
bool _isEditMode = false;
|
|
|
|
final List<String> _cropOptions = [
|
|
'Padi',
|
|
'Jagung',
|
|
'Kedelai',
|
|
'Cabai',
|
|
'Tomat',
|
|
'Bawang',
|
|
'Lainnya',
|
|
];
|
|
|
|
// FocusNode untuk mengelola fokus keyboard
|
|
final _cropNameFocus = FocusNode();
|
|
final _notesFocus = FocusNode();
|
|
final _seedCostFocus = FocusNode();
|
|
final _fertilizerCostFocus = FocusNode();
|
|
final _pesticideCostFocus = FocusNode();
|
|
final _irrigationCostFocus = FocusNode();
|
|
final _expectedYieldFocus = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_isEditMode = widget.scheduleToEdit != null;
|
|
|
|
if (_isEditMode) {
|
|
_initializeWithExistingData();
|
|
|
|
// Check if fields already have values when editing
|
|
Future.delayed(Duration.zero, () {
|
|
setState(() {
|
|
_seedCostValid = _seedCostController.text.isNotEmpty;
|
|
_fertilizerCostValid = _fertilizerCostController.text.isNotEmpty;
|
|
_pesticideCostValid = _pesticideCostController.text.isNotEmpty;
|
|
_irrigationCostValid = _irrigationCostController.text.isNotEmpty;
|
|
_expectedYieldValid = _expectedYieldController.text.isNotEmpty;
|
|
});
|
|
});
|
|
} else {
|
|
if (widget.initialStartDate != null) {
|
|
_startDate = widget.initialStartDate!;
|
|
_endDate = _startDate.add(const Duration(days: 90));
|
|
}
|
|
// Set default crop name, but leave costs empty
|
|
_cropNameController.text = 'Padi';
|
|
}
|
|
|
|
_loadFields();
|
|
|
|
// Konfigurasi focus nodes untuk auto-scroll saat keyboard muncul
|
|
_setupFocusNodes();
|
|
}
|
|
|
|
void _setupFocusNodes() {
|
|
// Fungsi untuk scroll ke field yang sedang difokuskan
|
|
void scrollToFocusedField(FocusNode focusNode) {
|
|
// Hapus kode yang mungkin mengganggu keyboard
|
|
}
|
|
|
|
// Tambahkan listener ke setiap focus node tanpa implementasi yang mengganggu keyboard
|
|
_seedCostFocus.addListener(() {});
|
|
_fertilizerCostFocus.addListener(() {});
|
|
_pesticideCostFocus.addListener(() {});
|
|
_irrigationCostFocus.addListener(() {});
|
|
_expectedYieldFocus.addListener(() {});
|
|
_notesFocus.addListener(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Dispose semua controller
|
|
_cropNameController.dispose();
|
|
_notesController.dispose();
|
|
_seedCostController.dispose();
|
|
_fertilizerCostController.dispose();
|
|
_pesticideCostController.dispose();
|
|
_irrigationCostController.dispose();
|
|
_expectedYieldController.dispose();
|
|
|
|
// Dispose semua focus node
|
|
_cropNameFocus.dispose();
|
|
_notesFocus.dispose();
|
|
_seedCostFocus.dispose();
|
|
_fertilizerCostFocus.dispose();
|
|
_pesticideCostFocus.dispose();
|
|
_irrigationCostFocus.dispose();
|
|
_expectedYieldFocus.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
String _formatCostForDisplay(dynamic cost) {
|
|
if (cost == null) return '';
|
|
String costStr = cost.toString();
|
|
// Remove .0 for whole numbers
|
|
if (costStr.endsWith('.0')) {
|
|
return costStr.substring(0, costStr.length - 2);
|
|
}
|
|
return costStr;
|
|
}
|
|
|
|
void _initializeWithExistingData() {
|
|
try {
|
|
final schedule = widget.scheduleToEdit!;
|
|
_cropNameController.text = schedule['crop_name'] ?? '';
|
|
_notesController.text = schedule['notes'] ?? '';
|
|
|
|
// Use the formatter to hide zero values
|
|
_seedCostController.text = _formatCostForDisplay(schedule['seed_cost']);
|
|
_fertilizerCostController.text = _formatCostForDisplay(
|
|
schedule['fertilizer_cost'],
|
|
);
|
|
_pesticideCostController.text = _formatCostForDisplay(
|
|
schedule['pesticide_cost'],
|
|
);
|
|
_irrigationCostController.text = _formatCostForDisplay(
|
|
schedule['irrigation_cost'],
|
|
);
|
|
_expectedYieldController.text = _formatCostForDisplay(
|
|
schedule['expected_yield'],
|
|
);
|
|
|
|
if (schedule['start_date'] != null) {
|
|
_startDate = DateTime.parse(schedule['start_date']);
|
|
}
|
|
if (schedule['end_date'] != null) {
|
|
_endDate = DateTime.parse(schedule['end_date']);
|
|
}
|
|
|
|
_selectedFieldId = schedule['field_id'];
|
|
_selectedPlot = schedule['plot'];
|
|
} catch (e) {
|
|
debugPrint('Error initializing data: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _loadFields() async {
|
|
if (!mounted) return;
|
|
setState(() => _isLoadingFields = true);
|
|
try {
|
|
final response = await Supabase.instance.client
|
|
.from('fields')
|
|
.select('id, name, plot_count')
|
|
.order('name', ascending: true)
|
|
.timeout(const Duration(seconds: 8));
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_fields = List<Map<String, dynamic>>.from(response);
|
|
if (_isEditMode && _selectedFieldId != null) {
|
|
_selectedFieldData = _fields.firstWhere(
|
|
(field) => field['id'] == _selectedFieldId,
|
|
orElse: () => _fields.isNotEmpty ? _fields.first : {},
|
|
);
|
|
} else if (_fields.isNotEmpty) {
|
|
_selectedFieldId ??= _fields.first['id'];
|
|
_selectedFieldData = _fields.first;
|
|
}
|
|
_isLoadingFields = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _isLoadingFields = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
e.toString().contains('timeout')
|
|
? 'Koneksi timeout.'
|
|
: 'Gagal memuat data lahan.',
|
|
),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<int> _getTakenPlots(
|
|
Map<String, dynamic> fieldData,
|
|
DateTime startDate,
|
|
DateTime endDate,
|
|
) {
|
|
if (fieldData['id'] == null) return [];
|
|
final takenPlotsSet = <int>{};
|
|
for (var schedule in widget.existingSchedules) {
|
|
if (schedule['field_id'] != fieldData['id']) continue;
|
|
if (_isEditMode && schedule['id'] == widget.scheduleToEdit!['id'])
|
|
continue;
|
|
|
|
final scheduleStartDate = DateTime.tryParse(schedule['start_date'] ?? '');
|
|
final scheduleEndDate = DateTime.tryParse(schedule['end_date'] ?? '');
|
|
if (scheduleStartDate == null || scheduleEndDate == null) continue;
|
|
|
|
final overlap =
|
|
(startDate.isBefore(scheduleEndDate) ||
|
|
startDate.isAtSameMomentAs(scheduleEndDate)) &&
|
|
(endDate.isAfter(scheduleStartDate) ||
|
|
endDate.isAtSameMomentAs(scheduleStartDate));
|
|
if (overlap && schedule['plot'] is int) {
|
|
takenPlotsSet.add(schedule['plot'] as int);
|
|
}
|
|
}
|
|
return takenPlotsSet.toList();
|
|
}
|
|
|
|
Future<void> _pickDate(bool isStartDate) async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: isStartDate ? _startDate : _endDate,
|
|
firstDate:
|
|
isStartDate
|
|
? DateTime.now().subtract(const Duration(days: 365))
|
|
: _startDate,
|
|
lastDate: DateTime.now().add(const Duration(days: 730)),
|
|
);
|
|
if (picked != null && mounted) {
|
|
setState(() {
|
|
if (isStartDate) {
|
|
_startDate = picked;
|
|
if (_endDate.isBefore(_startDate)) {
|
|
_endDate = _startDate.add(const Duration(days: 1));
|
|
}
|
|
} else {
|
|
_endDate = picked;
|
|
}
|
|
_selectedPlot = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
double _safeParseDouble(String? text) {
|
|
if (text == null || text.trim().isEmpty) return 0.0;
|
|
try {
|
|
final cleanText = text.trim().replaceAll(',', '.');
|
|
return double.tryParse(cleanText) ?? 0.0;
|
|
} catch (e) {
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_isLoading || _isSaved) return;
|
|
|
|
if (!_formKey.currentState!.validate()) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Mohon lengkapi data yang diperlukan.'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (mounted) setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final userId = Supabase.instance.client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('User tidak ditemukan.');
|
|
|
|
final data = {
|
|
'user_id': userId,
|
|
'crop_name': _cropNameController.text.trim(),
|
|
'start_date': _startDate.toIso8601String(),
|
|
'end_date': _endDate.toIso8601String(),
|
|
'field_id': _selectedFieldId,
|
|
'plot': _selectedPlot,
|
|
'notes':
|
|
_notesController.text.trim().isEmpty
|
|
? null
|
|
: _notesController.text.trim(),
|
|
'status': 'active',
|
|
'seed_cost': _safeParseDouble(_seedCostController.text),
|
|
'fertilizer_cost': _safeParseDouble(_fertilizerCostController.text),
|
|
'pesticide_cost': _safeParseDouble(_pesticideCostController.text),
|
|
'irrigation_cost': _safeParseDouble(_irrigationCostController.text),
|
|
'expected_yield': _safeParseDouble(_expectedYieldController.text),
|
|
};
|
|
|
|
if (!_isEditMode) {
|
|
data['id'] = const Uuid().v4();
|
|
data['created_at'] = DateTime.now().toIso8601String();
|
|
}
|
|
|
|
final scheduleId =
|
|
_isEditMode ? widget.scheduleToEdit!['id'] : data['id'];
|
|
|
|
if (_isEditMode) {
|
|
await Supabase.instance.client
|
|
.from('crop_schedules')
|
|
.update(data)
|
|
.eq('id', scheduleId);
|
|
} else {
|
|
await Supabase.instance.client.from('crop_schedules').insert(data);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isSaved = true);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Jadwal berhasil ${_isEditMode ? 'diperbarui' : 'disimpan'}.',
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
|
|
final newScheduleData =
|
|
await Supabase.instance.client
|
|
.from('crop_schedules')
|
|
.select()
|
|
.eq('id', scheduleId)
|
|
.single();
|
|
widget.onScheduleAdded?.call(newScheduleData);
|
|
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
if (mounted) Navigator.of(context).pop(true);
|
|
} catch (e) {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal menyimpan jadwal: ${e.toString()}'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
List<int> availablePlots = [];
|
|
if (_selectedFieldData != null &&
|
|
_selectedFieldData!['plot_count'] != null) {
|
|
final int plotCount = _selectedFieldData!['plot_count'] as int;
|
|
final takenPlots = _getTakenPlots(
|
|
_selectedFieldData!,
|
|
_startDate,
|
|
_endDate,
|
|
);
|
|
availablePlots =
|
|
List.generate(
|
|
plotCount,
|
|
(i) => i + 1,
|
|
).where((p) => !takenPlots.contains(p)).toList();
|
|
if (_selectedPlot != null && !availablePlots.contains(_selectedPlot)) {
|
|
_selectedPlot = null;
|
|
}
|
|
}
|
|
|
|
// Gunakan MediaQuery untuk mendapatkan ukuran keyboard
|
|
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header yang tetap di atas
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_isEditMode ? 'Edit Jadwal Tanam' : 'Tambah Jadwal Tanam',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Konten form yang dapat di-scroll
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.only(
|
|
bottom: keyboardHeight > 0 ? keyboardHeight + 80 : 20,
|
|
left: 20,
|
|
right: 20,
|
|
),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionTitle('Periode Tanam'),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildDateField(
|
|
'Tanggal Mulai',
|
|
_startDate,
|
|
() => _pickDate(true),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildDateField(
|
|
'Tanggal Selesai',
|
|
_endDate,
|
|
() => _pickDate(false),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildSectionTitle('Informasi Tanaman'),
|
|
const SizedBox(height: 12),
|
|
_buildDropdownField<String>(
|
|
value:
|
|
_cropOptions.contains(_cropNameController.text)
|
|
? _cropNameController.text
|
|
: 'Lainnya',
|
|
items: _cropOptions,
|
|
onChanged: (String? newValue) {
|
|
setState(() {
|
|
if (newValue != 'Lainnya') {
|
|
_cropNameController.text = newValue!;
|
|
} else {
|
|
_cropNameController.clear();
|
|
}
|
|
});
|
|
},
|
|
labelText: 'Nama Tanaman',
|
|
icon: Icons.eco,
|
|
),
|
|
if (!_cropOptions.contains(_cropNameController.text) ||
|
|
_cropNameController.text.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16.0),
|
|
child: _buildTextField(
|
|
controller: _cropNameController,
|
|
labelText: 'Nama Tanaman (Lainnya)',
|
|
icon: Icons.eco,
|
|
validator:
|
|
(v) =>
|
|
v == null || v.isEmpty
|
|
? 'Nama tanaman harus diisi'
|
|
: null,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildSectionTitle('Detail Lahan & Plot'),
|
|
const SizedBox(height: 12),
|
|
_isLoadingFields
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _fields.isEmpty
|
|
? _buildEmptyFieldsWarning()
|
|
: Column(
|
|
children: [
|
|
_buildDropdownField<String>(
|
|
value: _selectedFieldId,
|
|
items: _fields,
|
|
onChanged: (String? value) {
|
|
setState(() {
|
|
_selectedFieldId = value;
|
|
_selectedFieldData = _fields.firstWhere(
|
|
(f) => f['id'] == value,
|
|
orElse: () => {},
|
|
);
|
|
_selectedPlot = null;
|
|
});
|
|
},
|
|
labelText: 'Pilih Lahan',
|
|
icon: Icons.landscape,
|
|
),
|
|
if (_selectedFieldData != null &&
|
|
_selectedFieldData!['plot_count'] > 0) ...[
|
|
const SizedBox(height: 16),
|
|
_buildDropdownField<int>(
|
|
value: _selectedPlot,
|
|
items: availablePlots,
|
|
onChanged:
|
|
(int? value) =>
|
|
setState(() => _selectedPlot = value),
|
|
labelText: 'Pilih Nomor Plot',
|
|
icon: Icons.format_list_numbered,
|
|
hint:
|
|
availablePlots.isEmpty
|
|
? 'Tidak ada plot tersedia'
|
|
: null,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildSectionTitle('Estimasi Biaya *'),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Semua biaya harus diisi untuk analisis',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.red.shade700,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildCostTextField(
|
|
_seedCostController,
|
|
'Biaya Bibit (Rp)',
|
|
Icons.grass,
|
|
focusNode: _seedCostFocus,
|
|
isRequired: true,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Biaya bibit harus diisi';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildCostTextField(
|
|
_fertilizerCostController,
|
|
'Biaya Pupuk (Rp)',
|
|
Icons.local_florist,
|
|
focusNode: _fertilizerCostFocus,
|
|
isRequired: true,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Biaya pupuk harus diisi';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildCostTextField(
|
|
_pesticideCostController,
|
|
'Biaya Pestisida (Rp)',
|
|
Icons.bug_report,
|
|
focusNode: _pesticideCostFocus,
|
|
isRequired: true,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Biaya pestisida harus diisi';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildCostTextField(
|
|
_irrigationCostController,
|
|
'Biaya Irigasi (Rp)',
|
|
Icons.water_drop,
|
|
focusNode: _irrigationCostFocus,
|
|
isRequired: true,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Biaya irigasi harus diisi';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildSectionTitle('Estimasi Hasil Panen *'),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Data ini wajib diisi untuk analisis hasil panen',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.red.shade700,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildCostTextField(
|
|
_expectedYieldController,
|
|
'Hasil Panen (Kg)',
|
|
Icons.agriculture,
|
|
focusNode: _expectedYieldFocus,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Estimasi hasil panen harus diisi';
|
|
}
|
|
return null;
|
|
},
|
|
isRequired: true,
|
|
helperText: 'Wajib diisi untuk analisis hasil panen',
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildSectionTitle('Catatan Tambahan (Opsional)'),
|
|
const SizedBox(height: 12),
|
|
_buildTextField(
|
|
controller: _notesController,
|
|
labelText: 'Catatan',
|
|
icon: Icons.note,
|
|
maxLines: 3,
|
|
focusNode: _notesFocus,
|
|
),
|
|
|
|
// Tambahkan padding bawah untuk memastikan konten terlihat saat keyboard muncul
|
|
const SizedBox(height: 30),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Tombol simpan yang tetap di bawah
|
|
Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: ElevatedButton(
|
|
onPressed: _isSaved || _isLoading ? null : _submit,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF056839),
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child:
|
|
_isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.save, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Simpan Jadwal',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCostTextField(
|
|
TextEditingController controller,
|
|
String label,
|
|
IconData icon, {
|
|
FocusNode? focusNode,
|
|
String? Function(String?)? validator,
|
|
bool isRequired = false,
|
|
String? helperText,
|
|
}) {
|
|
// Determine which validation state to update
|
|
void Function(bool) updateValidState;
|
|
bool isValid = false;
|
|
|
|
if (controller == _seedCostController) {
|
|
updateValidState = (value) => setState(() => _seedCostValid = value);
|
|
isValid = _seedCostValid;
|
|
} else if (controller == _fertilizerCostController) {
|
|
updateValidState =
|
|
(value) => setState(() => _fertilizerCostValid = value);
|
|
isValid = _fertilizerCostValid;
|
|
} else if (controller == _pesticideCostController) {
|
|
updateValidState = (value) => setState(() => _pesticideCostValid = value);
|
|
isValid = _pesticideCostValid;
|
|
} else if (controller == _irrigationCostController) {
|
|
updateValidState =
|
|
(value) => setState(() => _irrigationCostValid = value);
|
|
isValid = _irrigationCostValid;
|
|
} else if (controller == _expectedYieldController) {
|
|
updateValidState = (value) => setState(() => _expectedYieldValid = value);
|
|
isValid = _expectedYieldValid;
|
|
} else {
|
|
updateValidState = (_) {};
|
|
}
|
|
|
|
return _buildTextField(
|
|
controller: controller,
|
|
labelText: label,
|
|
icon: icon,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
focusNode: focusNode,
|
|
validator: validator,
|
|
textInputAction: TextInputAction.next,
|
|
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
|
|
isRequired: isRequired,
|
|
helperText: helperText,
|
|
onChanged: (value) {
|
|
final isFieldValid = value.isNotEmpty;
|
|
updateValidState(isFieldValid);
|
|
// Trigger validation to update error messages
|
|
_formKey.currentState?.validate();
|
|
},
|
|
isValid: isValid,
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyFieldsWarning() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.orange.shade200),
|
|
),
|
|
child: const Column(
|
|
children: [
|
|
Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 32),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Belum ada lahan terdaftar.',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
'Silakan tambahkan lahan terlebih dahulu.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField({
|
|
required TextEditingController controller,
|
|
required String labelText,
|
|
IconData? icon,
|
|
String? Function(String?)? validator,
|
|
TextInputType keyboardType = TextInputType.text,
|
|
int? maxLines,
|
|
FocusNode? focusNode,
|
|
bool isRequired = false,
|
|
String? helperText,
|
|
TextInputAction? textInputAction,
|
|
void Function(String)? onFieldSubmitted,
|
|
void Function(String)? onChanged,
|
|
bool isValid = false,
|
|
}) {
|
|
return TextFormField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
autofocus: false,
|
|
decoration: InputDecoration(
|
|
labelText: isRequired ? '$labelText *' : labelText,
|
|
prefixIcon: icon != null ? Icon(icon) : null,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
helperText: helperText,
|
|
helperStyle: TextStyle(
|
|
color: isRequired ? Colors.red.shade700 : Colors.grey.shade600,
|
|
fontSize: 12,
|
|
),
|
|
// Show green border when valid
|
|
enabledBorder:
|
|
isValid
|
|
? OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(
|
|
color: Colors.green.shade500,
|
|
width: 1.5,
|
|
),
|
|
)
|
|
: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
// Change icon color to green when valid
|
|
prefixIconColor: isValid ? Colors.green : null,
|
|
),
|
|
keyboardType: keyboardType,
|
|
maxLines: maxLines,
|
|
onChanged: onChanged,
|
|
validator:
|
|
validator ??
|
|
(value) {
|
|
if (isRequired && (value == null || value.isEmpty)) {
|
|
return '$labelText harus diisi';
|
|
}
|
|
if (keyboardType ==
|
|
const TextInputType.numberWithOptions(decimal: true) &&
|
|
value != null &&
|
|
value.isNotEmpty) {
|
|
try {
|
|
final cleanText = value.trim().replaceAll(',', '.');
|
|
double.parse(cleanText);
|
|
} catch (e) {
|
|
return 'Format angka tidak valid';
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
textInputAction: textInputAction ?? TextInputAction.next,
|
|
onFieldSubmitted:
|
|
onFieldSubmitted ?? (_) => FocusScope.of(context).nextFocus(),
|
|
);
|
|
}
|
|
|
|
Widget _buildDropdownField<T>({
|
|
required T? value,
|
|
required List<dynamic> items,
|
|
required void Function(T?) onChanged,
|
|
required String labelText,
|
|
required IconData icon,
|
|
String? hint,
|
|
}) {
|
|
List<DropdownMenuItem<T>> dropdownItems = [];
|
|
if (items is List<String>) {
|
|
dropdownItems =
|
|
items
|
|
.map(
|
|
(item) => DropdownMenuItem<T>(
|
|
value: item as T,
|
|
child: Text(item, style: const TextStyle(fontSize: 14)),
|
|
),
|
|
)
|
|
.toList();
|
|
} else if (items is List<Map<String, dynamic>>) {
|
|
dropdownItems =
|
|
items
|
|
.map(
|
|
(field) => DropdownMenuItem<T>(
|
|
value: field['id'] as T,
|
|
child: Text(
|
|
field['name'] ?? 'Tanpa Nama',
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
),
|
|
)
|
|
.toList();
|
|
} else if (items is List<int>) {
|
|
dropdownItems =
|
|
items
|
|
.map(
|
|
(plot) => DropdownMenuItem<T>(
|
|
value: plot as T,
|
|
child: Text(
|
|
'Plot $plot',
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
final T? selectedValue =
|
|
(value != null &&
|
|
items.any(
|
|
(item) => (item is Map ? item['id'] == value : item == value),
|
|
))
|
|
? value
|
|
: null;
|
|
|
|
return DropdownButtonFormField<T>(
|
|
value: selectedValue,
|
|
items: dropdownItems,
|
|
onChanged: onChanged,
|
|
decoration: _inputDecoration(
|
|
labelText,
|
|
icon,
|
|
isValid: selectedValue != null,
|
|
),
|
|
isExpanded: true,
|
|
hint: Text(
|
|
hint ?? 'Pilih $labelText',
|
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
),
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
);
|
|
}
|
|
|
|
Widget _buildDateField(String label, DateTime date, VoidCallback onTap) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InputDecorator(
|
|
decoration: _inputDecoration(
|
|
label,
|
|
Icons.calendar_today,
|
|
isValid: true,
|
|
),
|
|
child: Text(
|
|
DateFormat('dd MMM yyyy').format(date),
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title) {
|
|
return Text(
|
|
title,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey.shade800,
|
|
),
|
|
);
|
|
}
|
|
|
|
InputDecoration _inputDecoration(
|
|
String labelText,
|
|
IconData icon, {
|
|
String? hintText,
|
|
bool isValid = false,
|
|
}) {
|
|
final Color primaryColor = const Color(0xFF056839);
|
|
final Color validColor = Colors.green.shade500;
|
|
|
|
return InputDecoration(
|
|
labelText: labelText,
|
|
hintText: hintText,
|
|
prefixIcon: Icon(icon, color: isValid ? validColor : primaryColor),
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: isValid ? validColor : Colors.grey.shade300,
|
|
width: isValid ? 1.5 : 1.0,
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: isValid ? validColor : primaryColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
labelStyle: TextStyle(fontSize: 14, color: isValid ? validColor : null),
|
|
hintStyle: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
);
|
|
}
|
|
}
|