MIF_E31231313/Android/flutter_app/lib/screens/add_child_screen.dart

704 lines
25 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../models/mobile_child_model.dart';
import '../services/api_service.dart';
import '../utils/nutrition_display_utils.dart';
import 'app_shell.dart';
class AddChildScreen extends StatefulWidget {
const AddChildScreen({
super.key,
bool isFirstSetup = false,
@Deprecated('Gunakan isFirstSetup') bool? isMandatory,
}) : isFirstSetup = isMandatory ?? isFirstSetup;
final bool isFirstSetup;
@override
State<AddChildScreen> createState() => _AddChildScreenState();
}
class _AddChildScreenState extends State<AddChildScreen>
with SingleTickerProviderStateMixin {
final _api = ApiService();
final _nameController = TextEditingController();
final _dateController = TextEditingController();
final _appState = SgiziAppState.instance;
late final AnimationController _shakeController;
DateTime? _birthDate;
String? _gender;
bool _loading = false;
bool _showValidation = false;
String? _error;
int? _selectedExistingChildId;
@override
void initState() {
super.initState();
_selectedExistingChildId = _appState.activeChildId;
_shakeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
);
}
@override
void dispose() {
_nameController.dispose();
_dateController.dispose();
_shakeController.dispose();
super.dispose();
}
bool get _isValid {
return _nameController.text.trim().isNotEmpty &&
_birthDate != null &&
_gender != null;
}
Future<void> _save() async {
FocusScope.of(context).unfocus();
if (!_isValid) {
setState(() {
_showValidation = true;
_error = 'Nama lengkap, jenis kelamin, dan tanggal lahir wajib diisi.';
});
_shakeController.forward(from: 0);
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final child = await _api.createChild({
'nama': _nameController.text.trim(),
'tanggal_lahir': _toApiDate(_birthDate!),
'jenis_kelamin': _gender,
});
final state = SgiziAppState.instance;
state.setChildren([...state.children, child]);
state.setActiveChild(child.id);
if (!mounted) return;
Navigator.of(context).pushReplacement(fadeRoute(const AppShell()));
} catch (error) {
setState(() => _error = error.toString());
_shakeController.forward(from: 0);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _pickDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _birthDate ?? DateTime(now.year - 3, now.month, now.day),
firstDate: DateTime(now.year - 18, now.month, now.day),
lastDate: now,
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: const Color(0xFF0B7A86),
),
),
child: child!,
);
},
);
if (picked == null) return;
setState(() {
_birthDate = picked;
_dateController.text = _formatIndonesiaDate(picked);
_showValidation = false;
_error = null;
});
}
@override
Widget build(BuildContext context) {
final children = _appState.children;
return PopScope(
canPop: !widget.isFirstSetup,
child: Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
automaticallyImplyLeading: !widget.isFirstSetup,
backgroundColor: const Color(0xFFF5F7F6),
title: Text(widget.isFirstSetup ? 'Data Anak' : 'Tambah Anak'),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.more_vert_rounded),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 10, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ANAK TERDAFTAR',
style: AppTypography.caption.copyWith(
letterSpacing: 1,
color: SgColors.textSecondary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
_ChildSelectorRow(
children: children,
selectedId: _selectedExistingChildId,
onSelect: (id) {
setState(() => _selectedExistingChildId = id);
_appState.setActiveChild(id);
},
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1),
const SizedBox(height: 18),
AnimatedBuilder(
animation: _shakeController,
builder: (context, child) {
final t = _shakeController.value;
final dx = math.sin(t * math.pi * 4) * (1 - t) * 8;
return Transform.translate(
offset: Offset(dx, 0),
child: child,
);
},
child: HealthCard(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Data Anak', style: AppTypography.h2),
const SizedBox(height: 4),
const Text(
'Lengkapi informasi untuk analisis gizi tepat.',
style: AppTypography.body,
),
const SizedBox(height: 18),
Center(
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: const Color(0xFFEFF9F8),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF0B7A86),
width: 2.2,
),
),
child: ClipOval(
child: Padding(
padding: const EdgeInsets.all(18),
child: Image.asset(
'assets/image/logo_sgizi.png',
fit: BoxFit.contain,
),
),
),
),
Positioned(
right: -2,
bottom: -2,
child: GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Upload foto akan tersedia pada versi berikutnya.',
),
),
);
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF0B7A86),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
LucideIcons.camera,
color: Colors.white,
size: 16,
),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
begin: const Offset(0.98, 0.98),
end: const Offset(1.02, 1.02),
duration: 2.seconds,
),
const SizedBox(height: 10),
Text(
'Unggah Foto Anak',
style: AppTypography.h3.copyWith(
color: const Color(0xFF0B7A86),
),
),
],
),
),
const SizedBox(height: 18),
_FieldLabel('NAMA LENGKAP'),
const SizedBox(height: 8),
TextField(
controller: _nameController,
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_showValidation) setState(() {});
},
decoration: _inputDecoration(
hint: 'Contoh: Arkan Syahputra',
icon: PhosphorIconsRegular.user,
showError:
_showValidation && _nameController.text.trim().isEmpty,
),
),
const SizedBox(height: 16),
_FieldLabel('JENIS KELAMIN'),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _GenderOption(
label: 'Laki-laki',
active: _gender == 'L',
onTap: () => setState(() => _gender = 'L'),
),
),
const SizedBox(width: 10),
Expanded(
child: _GenderOption(
label: 'Perempuan',
active: _gender == 'P',
onTap: () => setState(() => _gender = 'P'),
),
),
],
),
if (_showValidation && _gender == null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Pilih jenis kelamin.',
style: AppTypography.caption.copyWith(
color: SgColors.danger,
),
),
),
const SizedBox(height: 16),
_FieldLabel('TANGGAL LAHIR'),
const SizedBox(height: 8),
TextField(
controller: _dateController,
readOnly: true,
onTap: _pickDate,
decoration: _inputDecoration(
hint: 'Pilih tanggal lahir',
icon: LucideIcons.calendarDays,
showError: _showValidation && _birthDate == null,
),
),
const SizedBox(height: 10),
if (_birthDate != null)
Text(
'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!))}',
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: AppTypography.caption.copyWith(
color: SgColors.danger,
fontWeight: FontWeight.w700,
),
),
],
const SizedBox(height: 18),
_SaveButton(
loading: _loading,
onTap: _loading ? null : _save,
),
],
),
).animate().fadeIn(delay: 100.ms, duration: 320.ms).slideY(
begin: 0.1,
end: 0,
),
),
const SizedBox(height: 14),
HealthCard(
color: const Color(0xFFEFFAF4),
borderColor: const Color(0xFFD5EFD8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: const Color(0xFFDFF5E6),
child: Icon(
PhosphorIconsFill.shieldCheck,
size: 18,
color: const Color(0xFF28A66D),
),
),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Informasi Keamanan', style: AppTypography.h3),
SizedBox(height: 4),
Text(
'Data anak Anda tersimpan aman dan hanya digunakan untuk analisis standar WHO.',
style: AppTypography.body,
),
],
),
),
],
),
).animate().fadeIn(delay: 170.ms, duration: 320.ms),
],
),
),
),
),
);
}
InputDecoration _inputDecoration({
required String hint,
required IconData icon,
required bool showError,
}) {
return InputDecoration(
hintText: hint,
prefixIcon: Icon(icon),
filled: true,
fillColor: const Color(0xFFF4F7F6),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: showError ? SgColors.danger : const Color(0xFFE3E9E7),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: showError ? SgColors.danger : const Color(0xFFE3E9E7),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: const BorderSide(color: Color(0xFF0B7A86), width: 1.5),
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: AppTypography.caption.copyWith(
letterSpacing: 0.8,
fontWeight: FontWeight.w800,
color: SgColors.textPrimary,
),
);
}
}
class _GenderOption extends StatelessWidget {
const _GenderOption({
required this.label,
required this.active,
required this.onTap,
});
final String label;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? const Color(0xFF0B7A86).withValues(alpha: 0.10) : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4),
),
),
child: Text(
label,
style: AppTypography.h3.copyWith(
color: active ? const Color(0xFF0B7A86) : SgColors.textSecondary,
),
),
),
);
}
}
class _SaveButton extends StatefulWidget {
const _SaveButton({required this.loading, required this.onTap});
final bool loading;
final VoidCallback? onTap;
@override
State<_SaveButton> createState() => _SaveButtonState();
}
class _SaveButtonState extends State<_SaveButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapCancel: () => setState(() => _pressed = false),
onTapUp: (_) => setState(() => _pressed = false),
child: AnimatedScale(
scale: _pressed ? 0.98 : 1,
duration: const Duration(milliseconds: 160),
child: Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0B7A86), Color(0xFF1597A4)],
),
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
color: const Color(0xFF0B7A86).withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: widget.onTap,
child: Center(
child: widget.loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Simpan & Lanjutkan',
style: AppTypography.h2.copyWith(color: Colors.white),
),
const SizedBox(width: 10),
const Icon(LucideIcons.arrowRight, color: Colors.white),
],
),
),
),
),
),
),
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
begin: 0,
end: -2,
duration: 1800.ms,
);
}
}
class _ChildSelectorRow extends StatelessWidget {
const _ChildSelectorRow({
required this.children,
required this.selectedId,
required this.onSelect,
});
final List<MobileChildModel> children;
final int? selectedId;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 112,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: children.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
if (index == children.length) {
return Container(
width: 88,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE2EAE7)),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.plus, color: Color(0xFF0B7A86)),
SizedBox(height: 6),
Text('Tambah', style: AppTypography.caption),
],
),
);
}
final child = children[index];
final active = child.id == selectedId;
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => onSelect(child.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: 92,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2EAE7),
width: active ? 1.8 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: active ? 0.08 : 0.04),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Column(
children: [
Stack(
children: [
ChildAvatar(
name: child.nama,
gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 22,
),
if (active)
Positioned(
right: 0,
top: 0,
child: Container(
width: 14,
height: 14,
decoration: const BoxDecoration(
color: Color(0xFF0B7A86),
shape: BoxShape.circle,
),
),
),
],
),
const SizedBox(height: 8),
Text(
child.nama,
style: AppTypography.caption.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
formatAgeFromBirthDate(child.tanggalLahir),
style: AppTypography.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
);
}
}
String _toApiDate(DateTime value) {
return '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
String _formatIndonesiaDate(DateTime value) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
return '${value.day.toString().padLeft(2, '0')} ${months[value.month - 1]} ${value.year}';
}