Push Akhir

This commit is contained in:
Ranggaay 2026-07-22 21:17:24 +07:00
parent 0b9dfbea46
commit 90be585a76
274 changed files with 41202 additions and 6599 deletions

28
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "flutter_app",
"cwd": "Android\\flutter_app",
"request": "launch",
"type": "dart"
},
{
"name": "flutter_app (profile mode)",
"cwd": "Android\\flutter_app",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "flutter_app (release mode)",
"cwd": "Android\\flutter_app",
"request": "launch",
"type": "dart",
"flutterMode": "release"
}
]
}

View File

@ -9,6 +9,12 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# Legacy screens kept for reference during migration. Active app screens live
# under lib/features/** and lib/screens/nutritionist is superseded there.
- lib/screens/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@ -1,6 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }

View File

@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="s_gizi" android:label="s_gizi"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -2,10 +2,22 @@ import 'package:flutter/material.dart';
const String sgiziLogoAsset = 'assets/image/logo_sgizi.png'; const String sgiziLogoAsset = 'assets/image/logo_sgizi.png';
/// Spacing ringkas untuk layout production-ready.
class SgSpacing {
const SgSpacing._();
static const pageH = 16.0;
static const pageV = 12.0;
static const section = 14.0;
static const item = 8.0;
static const cardPad = 12.0;
}
class SgColors { class SgColors {
const SgColors._(); const SgColors._();
static const primary = Color(0xFF4B8E96); static const primary = Color(0xFF4B8E96);
static const primaryTeal = Color(0xFF0B7A86);
static const primaryDark = Color(0xFF2F737A); static const primaryDark = Color(0xFF2F737A);
static const secondary = Color(0xFFA8D5BA); static const secondary = Color(0xFFA8D5BA);
static const background = Color(0xFFF5F7F6); static const background = Color(0xFFF5F7F6);
@ -71,7 +83,7 @@ class AppLogo extends StatelessWidget {
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, _, _) => Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -97,42 +109,101 @@ class AppLogo extends StatelessWidget {
} }
} }
class SgAvatar extends StatelessWidget {
const SgAvatar({
super.key,
required this.name,
this.radius = 28,
this.gender,
this.icon,
});
final String name;
final String? gender;
final double radius;
final IconData? icon;
@override
Widget build(BuildContext context) {
final initial = getInitialName(name);
final colors = avatarGradientColors(name);
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: colors.last.withValues(alpha: 0.22),
blurRadius: radius * 0.55,
offset: Offset(0, radius * 0.2),
),
],
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: radius * 0.82,
height: 1,
),
),
);
}
}
String getInitialName(String? name) {
final value = (name ?? '').trim();
if (value.isEmpty) return 'A';
final first = value.characters.first.toUpperCase();
return RegExp(r'[A-Z0-9]').hasMatch(first) ? first : 'A';
}
List<Color> avatarGradientColors(String? seed) {
const palettes = [
[Color(0xFF4B8E96), Color(0xFF6FC7C8)],
[Color(0xFF5B8DEF), Color(0xFF8DB7FF)],
[Color(0xFF58B98B), Color(0xFF9DDFC1)],
[Color(0xFFE89B5B), Color(0xFFFFC08A)],
[Color(0xFF8B7AE6), Color(0xFFC2B7FF)],
[Color(0xFFE06F91), Color(0xFFFFA9BD)],
];
final text = (seed ?? '').trim();
if (text.isEmpty) return palettes.first;
final hash = text.codeUnits.fold<int>(
0,
(value, code) => (value + code) & 0x7fffffff,
);
return palettes[hash % palettes.length];
}
class ChildAvatar extends StatelessWidget { class ChildAvatar extends StatelessWidget {
const ChildAvatar({ const ChildAvatar({
super.key, super.key,
required this.name, required this.name,
required this.gender, required this.gender,
this.photoUrl,
this.radius = 28, this.radius = 28,
}); });
final String name; final String name;
final String gender; final String gender;
final String? photoUrl;
final double radius; final double radius;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final imageUrl = photoUrl?.trim(); return SgAvatar(
if (imageUrl != null && imageUrl.isNotEmpty) { name: name,
return CircleAvatar( gender: gender,
radius: radius, radius: radius,
backgroundColor: const Color(0xFFD9EEE7), icon: Icons.child_care_rounded,
backgroundImage: NetworkImage(imageUrl),
);
}
final isFemale = gender.toLowerCase().startsWith('p');
final accent = isFemale ? const Color(0xFFFF8FA3) : SgColors.primary;
return CircleAvatar(
radius: radius,
backgroundColor: accent.withValues(alpha: 0.12),
child: Icon(
isFemale ? Icons.face_3_rounded : Icons.face_rounded,
color: accent,
size: radius,
),
); );
} }
} }
@ -141,11 +212,12 @@ class HealthCard extends StatelessWidget {
const HealthCard({ const HealthCard({
super.key, super.key,
required this.child, required this.child,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(SgSpacing.cardPad),
this.margin, this.margin,
this.color = SgColors.surface, this.color = SgColors.surface,
this.borderColor, this.borderColor,
this.onTap, this.onTap,
this.dense = false,
}); });
final Widget child; final Widget child;
@ -154,9 +226,11 @@ class HealthCard extends StatelessWidget {
final Color color; final Color color;
final Color? borderColor; final Color? borderColor;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool dense;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final radius = dense ? 16.0 : 18.0;
final card = AnimatedContainer( final card = AnimatedContainer(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
curve: Curves.easeOut, curve: Curves.easeOut,
@ -164,13 +238,13 @@ class HealthCard extends StatelessWidget {
padding: padding, padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? SgColors.border), border: Border.all(color: borderColor ?? SgColors.border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.06), color: Colors.black.withValues(alpha: dense ? 0.04 : 0.06),
blurRadius: 20, blurRadius: dense ? 12 : 16,
offset: const Offset(0, 10), offset: Offset(0, dense ? 6 : 8),
), ),
], ],
), ),
@ -184,7 +258,7 @@ class HealthCard extends StatelessWidget {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(radius),
onTap: onTap, onTap: onTap,
child: card, child: card,
), ),
@ -197,25 +271,33 @@ class StatusBadge extends StatelessWidget {
super.key, super.key,
required this.text, required this.text,
this.color = SgColors.success, this.color = SgColors.success,
this.compact = false,
}); });
final String text; final String text;
final Color color; final Color color;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 10,
vertical: compact ? 4 : 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.16), color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.36)), border: Border.all(color: color.withValues(alpha: 0.32)),
), ),
child: Text( child: Text(
text, text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith( style: AppTypography.caption.copyWith(
color: color, color: color,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
fontSize: compact ? 11 : 12,
), ),
), ),
); );
@ -298,37 +380,50 @@ class EmptyState extends StatelessWidget {
required this.message, required this.message,
this.actionLabel, this.actionLabel,
this.onAction, this.onAction,
this.icon = Icons.inbox_outlined,
this.assetImage,
}); });
final String title; final String title;
final String message; final String message;
final String? actionLabel; final String? actionLabel;
final VoidCallback? onAction; final VoidCallback? onAction;
final IconData icon;
final String? assetImage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(20),
child: HealthCard( child: HealthCard(
dense: true,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircleAvatar( if (assetImage != null)
radius: 32, ClipRRect(
backgroundColor: Color(0xFFE9F6F2), borderRadius: BorderRadius.circular(16),
child: Icon(Icons.inbox_outlined, color: SgColors.primary), child: Image.asset(
assetImage!,
height: 88,
width: 88,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _emptyIcon(icon),
), ),
const SizedBox(height: 16), )
else
_emptyIcon(icon),
const SizedBox(height: 12),
Text(title, style: AppTypography.h2, textAlign: TextAlign.center), Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
const SizedBox(height: 8), const SizedBox(height: 6),
Text( Text(
message, message,
style: AppTypography.body, style: AppTypography.body.copyWith(fontSize: 13),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (actionLabel != null && onAction != null) ...[ if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16), const SizedBox(height: 12),
PrimaryButton(label: actionLabel!, onPressed: onAction), PrimaryButton(label: actionLabel!, onPressed: onAction),
], ],
], ],
@ -339,11 +434,29 @@ class EmptyState extends StatelessWidget {
} }
} }
class ErrorState extends StatelessWidget { Widget _emptyIcon(IconData icon) {
const ErrorState({super.key, required this.message, required this.onRetry}); return CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFE9F6F2),
child: Icon(icon, color: SgColors.primary, size: 28),
);
}
class ErrorState extends StatelessWidget {
const ErrorState({
super.key,
required this.message,
required this.onRetry,
this.title = 'Terjadi Kendala',
this.icon = Icons.wifi_off_rounded,
this.color = SgColors.danger,
});
final String title;
final String message; final String message;
final VoidCallback onRetry; final VoidCallback onRetry;
final IconData icon;
final Color color;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -354,13 +467,13 @@ class ErrorState extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircleAvatar( CircleAvatar(
radius: 32, radius: 32,
backgroundColor: Color(0xFFFFEBEE), backgroundColor: color.withValues(alpha: 0.12),
child: Icon(Icons.wifi_off_rounded, color: SgColors.danger), child: Icon(icon, color: color),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Terjadi Kendala', style: AppTypography.h2), Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
message, message,
@ -476,8 +589,8 @@ PageRouteBuilder<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>( return PageRouteBuilder<T>(
transitionDuration: const Duration(milliseconds: 260), transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220), reverseTransitionDuration: const Duration(milliseconds: 220),
pageBuilder: (_, __, ___) => page, pageBuilder: (_, _, _) => page,
transitionsBuilder: (_, animation, __, child) { transitionsBuilder: (_, animation, _, child) {
return FadeTransition( return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: SlideTransition( child: SlideTransition(

View File

@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart'; import 'dart:convert';
import 'models/mobile_child_model.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:s_gizi/models/mobile_child_model.dart';
class SgiziAppState extends ChangeNotifier { class SgiziAppState extends ChangeNotifier {
SgiziAppState._(); SgiziAppState._();
@ -8,9 +11,12 @@ class SgiziAppState extends ChangeNotifier {
static final SgiziAppState instance = SgiziAppState._(); static final SgiziAppState instance = SgiziAppState._();
String? authToken; String? authToken;
String? role;
int? activeChildId; int? activeChildId;
bool showFamilyOverviewOnHome = true;
List<MobileChildModel> children = const []; List<MobileChildModel> children = const [];
Map<String, dynamic>? profileData; Map<String, dynamic>? profileData;
Map<String, dynamic>? userData;
bool get isAuthenticated => authToken != null && authToken!.isNotEmpty; bool get isAuthenticated => authToken != null && authToken!.isNotEmpty;
MobileChildModel? get activeChild { MobileChildModel? get activeChild {
@ -27,22 +33,81 @@ class SgiziAppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> restoreSession() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final storedRole = prefs.getString(_roleKey);
final userJson = prefs.getString(_userKey);
authToken = token;
role = storedRole;
if (userJson != null && userJson.isNotEmpty) {
try {
final decoded = jsonDecode(userJson);
if (decoded is Map<String, dynamic>) {
userData = decoded;
profileData = decoded;
}
} catch (_) {
userData = null;
profileData = null;
}
}
notifyListeners();
}
Future<void> saveSession({
required String token,
required String role,
required Map<String, dynamic> user,
}) async {
authToken = token;
this.role = role;
userData = user;
profileData = user;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_roleKey, role);
await prefs.setString(_userKey, jsonEncode(user));
notifyListeners();
}
void setChildren(List<MobileChildModel> value) { void setChildren(List<MobileChildModel> value) {
children = value; children = value;
activeChildId ??= value.isNotEmpty ? value.first.id : null; if (value.isEmpty) {
if (value.every((child) => child.id != activeChildId)) { activeChildId = null;
activeChildId = value.isNotEmpty ? value.first.id : null; showFamilyOverviewOnHome = true;
} else if (value.length == 1) {
activeChildId = value.first.id;
showFamilyOverviewOnHome = false;
} else if (value.every((child) => child.id != activeChildId)) {
activeChildId = null;
showFamilyOverviewOnHome = true;
} }
notifyListeners(); notifyListeners();
} }
void setProfileData(Map<String, dynamic> value) { void setProfileData(Map<String, dynamic> value) {
profileData = value; profileData = value;
userData = value;
notifyListeners(); notifyListeners();
} }
void setActiveChild(int id) { void setActiveChild(int id) {
activeChildId = id; activeChildId = id;
showFamilyOverviewOnHome = false;
notifyListeners();
}
void showFamilyOverview() {
showFamilyOverviewOnHome = true;
notifyListeners();
}
void resetActiveChild() {
activeChildId = null;
showFamilyOverviewOnHome = true;
notifyListeners(); notifyListeners();
} }
@ -64,11 +129,22 @@ class SgiziAppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void logout() { Future<void> logout() async {
authToken = null; authToken = null;
role = null;
activeChildId = null; activeChildId = null;
showFamilyOverviewOnHome = true;
children = const []; children = const [];
profileData = null; profileData = null;
userData = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_roleKey);
await prefs.remove(_userKey);
notifyListeners(); notifyListeners();
} }
} }
const _tokenKey = 'sgizi_auth_token';
const _roleKey = 'sgizi_user_role';
const _userKey = 'sgizi_user_data';

View File

@ -0,0 +1,270 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
enum NutritionIndicator { bbtb, tbu, bbu, combined }
enum NutritionRiskLevel { unknown, normal, attention, high }
class NutritionStatusResult {
const NutritionStatusResult({
required this.label,
required this.color,
required this.icon,
required this.recommendation,
required this.riskLevel,
required this.badges,
});
final String label;
final Color color;
final IconData icon;
final String recommendation;
final NutritionRiskLevel riskLevel;
final List<String> badges;
bool get isNormal => riskLevel == NutritionRiskLevel.normal;
bool get isHighRisk => riskLevel == NutritionRiskLevel.high;
bool get needsAttention =>
riskLevel == NutritionRiskLevel.attention ||
riskLevel == NutritionRiskLevel.high;
}
class NutritionStatusHelper {
const NutritionStatusHelper._();
static const belumDiukur = 'Belum Diukur';
static const giziBuruk = 'Gizi Buruk';
static const giziKurang = 'Gizi Kurang';
static const giziBaik = 'Gizi Baik';
static const risikoBeratBadanLebih = 'Risiko Berat Badan Lebih';
static const giziLebih = 'Gizi Lebih';
static const obesitas = 'Obesitas';
static const sangatPendek = 'Sangat Pendek';
static const pendek = 'Pendek';
static const normal = 'Normal';
static const tinggi = 'Tinggi';
static const beratBadanSangatKurang = 'Berat Badan Sangat Kurang';
static const beratBadanKurang = 'Berat Badan Kurang';
static const beratBadanNormal = 'Berat Badan Normal';
static NutritionStatusResult getStatus({
String? status,
double? zBbu,
double? zTbu,
double? zBbtb,
NutritionIndicator indicator = NutritionIndicator.combined,
bool debug = false,
String source = 'NutritionStatusHelper',
}) {
final label = _resolveLabel(
status: status,
zBbu: zBbu,
zTbu: zTbu,
zBbtb: zBbtb,
indicator: indicator,
);
final badges = _badgesFor(label);
final primary = badges.isEmpty ? label : badges.first;
final result = NutritionStatusResult(
label: primary,
color: colorFor(primary),
icon: iconFor(primary),
recommendation: recommendationFor(primary),
riskLevel: riskLevelFor(primary),
badges: badges.isEmpty ? [primary] : badges,
);
if (debug || kDebugMode) {
debugPrint(
'[nutrition-status][$source] indicator=${indicator.name} '
'z_bbu=${_debugNum(zBbu)} z_tbu=${_debugNum(zTbu)} '
'z_bbtb=${_debugNum(zBbtb)} raw="${status ?? ''}" '
'final="${result.label}" badges=${result.badges.join('|')}',
);
}
return result;
}
static String bbtbFromZ(double? z) {
if (!_valid(z)) return belumDiukur;
if (z! < -3) return giziBuruk;
if (z < -2) return giziKurang;
if (z <= 1) return giziBaik;
if (z <= 2) return risikoBeratBadanLebih;
if (z <= 3) return giziLebih;
return obesitas;
}
static String tbuFromZ(double? z) {
if (!_valid(z)) return belumDiukur;
if (z! < -3) return sangatPendek;
if (z < -2) return pendek;
if (z <= 3) return normal;
return tinggi;
}
static String bbuFromZ(double? z) {
if (!_valid(z)) return belumDiukur;
if (z! < -3) return beratBadanSangatKurang;
if (z < -2) return beratBadanKurang;
if (z <= 1) return beratBadanNormal;
return risikoBeratBadanLebih;
}
static String localize(String? raw) {
final value = (raw ?? '').trim();
if (value.isEmpty || value == '-') return belumDiukur;
final lower = value.toLowerCase();
final replacements = <String, String>{
'severely stunted': sangatPendek,
'stunting berat': sangatPendek,
'stunted': pendek,
'stunting': pendek,
'severe wasting': giziBuruk,
'wasting': giziKurang,
'severely wasted': giziBuruk,
'severe underweight': beratBadanSangatKurang,
'severely underweight': beratBadanSangatKurang,
'underweight': beratBadanKurang,
'risk of overweight': risikoBeratBadanLebih,
'overweight': giziLebih,
'obese': obesitas,
'obesity': obesitas,
'normal': giziBaik,
'berat badan lebih': risikoBeratBadanLebih,
'risiko lebih': risikoBeratBadanLebih,
'risiko gizi lebih': risikoBeratBadanLebih,
'risiko berat badan lebih': risikoBeratBadanLebih,
};
var output = value;
for (final entry in replacements.entries) {
output = output.replaceAll(
RegExp(entry.key, caseSensitive: false),
entry.value,
);
}
if (lower == 'normal') return giziBaik;
return output;
}
static Color colorFor(String status) {
final label = localize(status).toLowerCase();
if (label.contains('gizi buruk') ||
label.contains('sangat pendek') ||
label.contains('sangat kurang')) {
return SgColors.danger;
}
if (label.contains('obesitas')) return const Color(0xFF991B1B);
if (label.contains('gizi lebih')) return const Color(0xFFEA580C);
if (label.contains('risiko berat badan lebih')) {
return const Color(0xFFEAB308);
}
if (label.contains('gizi kurang') ||
label.contains('pendek') ||
label.contains('berat badan kurang')) {
return SgColors.warning;
}
if (label.contains('belum')) return SgColors.textSecondary;
return SgColors.success;
}
static IconData iconFor(String status) {
final label = localize(status).toLowerCase();
if (label.contains('buruk') ||
label.contains('obesitas') ||
label.contains('sangat')) {
return Icons.warning_amber_rounded;
}
if (label.contains('kurang') || label.contains('pendek')) {
return Icons.monitor_weight_outlined;
}
if (label.contains('lebih') || label.contains('risiko')) {
return Icons.balance_rounded;
}
if (label.contains('belum')) return Icons.help_outline_rounded;
return Icons.favorite_rounded;
}
static NutritionRiskLevel riskLevelFor(String status) {
final label = localize(status).toLowerCase();
if (label.contains('belum')) return NutritionRiskLevel.unknown;
if (label.contains('buruk') ||
label.contains('sangat') ||
label.contains('obesitas')) {
return NutritionRiskLevel.high;
}
if (label.contains('kurang') ||
label.contains('pendek') ||
label.contains('lebih') ||
label.contains('risiko')) {
return NutritionRiskLevel.attention;
}
return NutritionRiskLevel.normal;
}
static String recommendationFor(String status) {
final label = localize(status).toLowerCase();
if (label.contains('gizi buruk') || label.contains('sangat kurang')) {
return 'Segera konsultasi dengan tenaga kesehatan dan pantau asupan energi-protein.';
}
if (label.contains('sangat pendek') || label.contains('pendek')) {
return 'Fokus pada protein hewani, zat besi, zinc, dan pemantauan tinggi badan rutin.';
}
if (label.contains('gizi kurang') || label.contains('berat badan kurang')) {
return 'Tingkatkan makanan padat energi dan protein secara bertahap.';
}
if (label.contains('obesitas') ||
label.contains('gizi lebih') ||
label.contains('risiko berat badan lebih')) {
return 'Atur porsi, batasi gula/lemak berlebih, dan dorong aktivitas sesuai usia.';
}
if (label.contains('belum')) {
return 'Lengkapi pengukuran berat, tinggi, usia, dan jenis kelamin anak.';
}
return 'Pertahankan pola makan seimbang dan lakukan pengukuran rutin.';
}
static String _resolveLabel({
String? status,
double? zBbu,
double? zTbu,
double? zBbtb,
required NutritionIndicator indicator,
}) {
switch (indicator) {
case NutritionIndicator.bbtb:
return bbtbFromZ(zBbtb);
case NutritionIndicator.tbu:
return tbuFromZ(zTbu);
case NutritionIndicator.bbu:
return bbuFromZ(zBbu);
case NutritionIndicator.combined:
final direct = (status ?? '').trim();
if (direct.isNotEmpty && direct != '-') return localize(direct);
if (_valid(zBbtb)) return bbtbFromZ(zBbtb);
if (_valid(zTbu)) return tbuFromZ(zTbu);
if (_valid(zBbu)) return bbuFromZ(zBbu);
return belumDiukur;
}
}
static List<String> _badgesFor(String status) {
final localized = localize(status);
if (localized == belumDiukur) return [belumDiukur];
final parts = localized
.split(RegExp(r'\s*\+\s*'))
.map((part) => localize(part).trim())
.where((part) => part.isNotEmpty)
.toSet()
.toList();
return parts.isEmpty ? [localized] : parts;
}
static bool _valid(double? value) =>
value != null && !value.isNaN && !value.isInfinite;
static String _debugNum(double? value) =>
_valid(value) ? value!.toStringAsFixed(2) : '-';
}

View File

@ -0,0 +1,479 @@
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:shimmer/shimmer.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/news_article_model.dart';
class ArticleDetailScreen extends StatelessWidget {
const ArticleDetailScreen({
super.key,
required this.article,
required this.related,
});
final NewsArticleModel article;
final List<NewsArticleModel> related;
Future<void> _share(BuildContext context) async {
final text = [
article.title,
if ((article.url ?? '').trim().isNotEmpty) article.url!,
].join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link artikel disalin untuk dibagikan.')),
);
}
@override
Widget build(BuildContext context) {
final body = article.content.trim().isEmpty
? article.description
: article.content;
final relatedItems = related
.where((item) => item.id != article.id)
.take(8)
.toList();
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(
title: const Text('Detail Artikel'),
actions: [
IconButton(
tooltip: 'Bagikan artikel',
onPressed: () => _share(context),
icon: const Icon(LucideIcons.share2),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'article-${article.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: AspectRatio(
aspectRatio: 16 / 9,
child: _ArticleImage(
imageUrl: article.image,
fallbackIndex: article.id,
),
),
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
StatusBadge(text: article.category, color: SgColors.primary),
StatusBadge(
text: '${_readingMinutes(body)} menit baca',
color: const Color(0xFF5B8DEF),
),
],
),
const SizedBox(height: 12),
Text(
article.title,
style: AppTypography.h1.copyWith(fontSize: 24),
),
const SizedBox(height: 10),
_ArticleMeta(article: article),
const SizedBox(height: 16),
if (article.description.trim().isNotEmpty)
HealthCard(
dense: true,
color: const Color(0xFFF8FCFB),
child: Text(
article.description,
style: AppTypography.body.copyWith(
color: SgColors.textPrimary,
height: 1.55,
),
),
),
const SizedBox(height: 16),
HealthCard(dense: true, child: _HtmlArticleBody(html: body)),
if (article.url != null && article.url!.isNotEmpty) ...[
const SizedBox(height: 16),
PrimaryButton(
label: 'Baca Sumber Artikel',
icon: Icons.open_in_new_rounded,
onPressed: () async {
final uri = Uri.tryParse(article.url!);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
},
),
],
if (relatedItems.isNotEmpty) ...[
const SizedBox(height: 22),
Text('Artikel Terkait', style: AppTypography.h2),
const SizedBox(height: 10),
SizedBox(
height: 136,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: relatedItems.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final item = relatedItems[index];
return _RelatedArticleCard(
article: item,
related: related,
index: index,
);
},
),
),
],
],
),
),
),
);
}
}
class _ArticleMeta extends StatelessWidget {
const _ArticleMeta({required this.article});
final NewsArticleModel article;
@override
Widget build(BuildContext context) {
final source = (article.sourceName ?? 'S-Gizi').trim();
final author = _shortAuthor(article.author);
final date = _formatArticleDate(article.createdAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$source$author',
style: AppTypography.caption.copyWith(fontWeight: FontWeight.w800),
),
if (date.isNotEmpty) ...[
const SizedBox(height: 3),
Text(date, style: AppTypography.caption),
],
],
);
}
}
class _HtmlArticleBody extends StatelessWidget {
const _HtmlArticleBody({required this.html});
final String html;
@override
Widget build(BuildContext context) {
final blocks = _parseHtmlBlocks(html);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: blocks
.map((block) => _ArticleBlockView(block: block))
.toList(growable: false),
);
}
}
class _ArticleBlockView extends StatelessWidget {
const _ArticleBlockView({required this.block});
final _ArticleBlock block;
@override
Widget build(BuildContext context) {
final style = switch (block.type) {
_ArticleBlockType.heading => AppTypography.h2.copyWith(height: 1.35),
_ArticleBlockType.list => AppTypography.body.copyWith(
height: 1.6,
color: SgColors.textPrimary,
),
_ArticleBlockType.paragraph => AppTypography.body.copyWith(
height: 1.65,
color: SgColors.textPrimary,
),
};
if (block.type == _ArticleBlockType.list) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(Icons.circle, size: 6, color: SgColors.primary),
),
const SizedBox(width: 10),
Expanded(child: SelectableText(block.text, style: style)),
],
),
);
}
return Padding(
padding: EdgeInsets.only(
bottom: block.type == _ArticleBlockType.heading ? 10 : 12,
top: block.type == _ArticleBlockType.heading ? 6 : 0,
),
child: SelectableText(block.text, style: style),
);
}
}
class _RelatedArticleCard extends StatelessWidget {
const _RelatedArticleCard({
required this.article,
required this.related,
required this.index,
});
final NewsArticleModel article;
final List<NewsArticleModel> related;
final int index;
@override
Widget build(BuildContext context) {
final width = math.min(260.0, MediaQuery.sizeOf(context).width * 0.72);
return SizedBox(
width: width,
child: HealthCard(
dense: true,
padding: const EdgeInsets.all(8),
onTap: () => Navigator.of(context).push(
fadeRoute(ArticleDetailScreen(article: article, related: related)),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 72,
height: 92,
child: _ArticleImage(
imageUrl: article.image,
fallbackIndex: article.id + index,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
StatusBadge(
text: article.category,
color: SgColors.primary,
compact: true,
),
const SizedBox(height: 6),
Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
],
),
),
],
),
),
);
}
}
class _ArticleImage extends StatelessWidget {
const _ArticleImage({required this.imageUrl, required this.fallbackIndex});
final String? imageUrl;
final int fallbackIndex;
@override
Widget build(BuildContext context) {
final fallback = _assetByIndex(fallbackIndex);
if (imageUrl == null || imageUrl!.isEmpty) {
return _ImagePlaceholder(asset: fallback);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 900,
maxWidthDiskCache: 900,
placeholder: (context, url) => const _ImageSkeleton(),
errorWidget: (context, url, error) => _ImagePlaceholder(asset: fallback),
);
}
}
class _ImageSkeleton extends StatelessWidget {
const _ImageSkeleton();
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: const Color(0xFFE6EEEC),
highlightColor: const Color(0xFFF8FAF9),
child: Container(color: Colors.white),
);
}
}
class _ImagePlaceholder extends StatelessWidget {
const _ImagePlaceholder({required this.asset});
final String asset;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(asset, fit: BoxFit.cover),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.04),
),
),
],
);
}
}
enum _ArticleBlockType { heading, paragraph, list }
class _ArticleBlock {
const _ArticleBlock(this.type, this.text);
final _ArticleBlockType type;
final String text;
}
List<_ArticleBlock> _parseHtmlBlocks(String raw) {
var html = raw.trim();
if (html.isEmpty) return const [];
html = html
.replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'</\s*h[1-6]\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'</\s*li\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<\s*li[^>]*>', caseSensitive: false), '\n- ')
.replaceAll(RegExp(r'</?\s*(ul|ol)[^>]*>', caseSensitive: false), '\n');
final blocks = <_ArticleBlock>[];
final headingMatches = RegExp(
r'<\s*h[1-6][^>]*>(.*?)</\s*h[1-6]\s*>',
caseSensitive: false,
dotAll: true,
).allMatches(raw);
final headings = {
for (final match in headingMatches) _cleanHtmlText(match.group(1) ?? ''),
};
for (final chunk in html.split(RegExp(r'\n{2,}'))) {
final lines = chunk
.split('\n')
.map(_cleanHtmlText)
.where((line) => line.isNotEmpty)
.toList();
if (lines.isEmpty) continue;
for (final line in lines) {
if (line.startsWith('- ')) {
blocks.add(_ArticleBlock(_ArticleBlockType.list, line.substring(2)));
} else if (headings.contains(line)) {
blocks.add(_ArticleBlock(_ArticleBlockType.heading, line));
} else {
blocks.add(_ArticleBlock(_ArticleBlockType.paragraph, line));
}
}
}
if (blocks.isEmpty) {
final text = _cleanHtmlText(raw);
if (text.isNotEmpty) {
blocks.add(_ArticleBlock(_ArticleBlockType.paragraph, text));
}
}
return blocks;
}
String _cleanHtmlText(String value) {
final noTags = value
.replaceAll(
RegExp(r'<\s*/?\s*(strong|b|em|i|span|a)[^>]*>', caseSensitive: false),
'',
)
.replaceAll(RegExp(r'<[^>]+>'), ' ');
return _decodeText(noTags);
}
String _decodeText(String value) {
return value
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
int _readingMinutes(String html) {
final words = _decodeText(
html.replaceAll(RegExp(r'<[^>]+>'), ' '),
).split(RegExp(r'\s+')).where((word) => word.trim().isNotEmpty).length;
return math.max(1, (words / 180).ceil());
}
String _formatArticleDate(String raw) {
final date = DateTime.tryParse(raw)?.toLocal();
if (date == null) return raw.trim();
const months = [
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
String _shortAuthor(String? raw) {
final value = (raw ?? 'Admin').trim();
if (value.toLowerCase() == 'admin s-gizi') return 'Admin';
return value.isEmpty ? 'Admin' : value;
}
String _assetByIndex(int index) {
const images = [
'assets/image/onboarding_food.png',
'assets/image/onboarding_monitoring.png',
'assets/image/onboarding_consultation.png',
];
return images[index.abs() % images.length];
}

View File

@ -0,0 +1,671 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/news_article_model.dart';
import 'package:s_gizi/services/article_service.dart';
import 'package:s_gizi/features/articles/screens/article_detail_screen.dart';
class ArticlesScreen extends StatefulWidget {
const ArticlesScreen({
super.key,
required this.title,
required this.articles,
this.initialCategory = 'Semua',
});
final String title;
final List<NewsArticleModel> articles;
final String initialCategory;
@override
State<ArticlesScreen> createState() => _ArticlesScreenState();
}
class _ArticlesScreenState extends State<ArticlesScreen> {
final ArticleService _articleService = ArticleService();
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final Set<int> _bookmarked = {};
final List<NewsArticleModel> _articles = [];
final List<NewsArticleModel> _recentlyViewed = [];
Timer? _debounce;
String _activeCategory = 'Semua';
int _page = 1;
bool _loading = true;
bool _loadingMore = false;
bool _hasMore = true;
String? _error;
@override
void initState() {
super.initState();
_activeCategory = widget.initialCategory;
_scrollController.addListener(_handleScroll);
_loadInitial();
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
void _handleScroll() {
if (!_scrollController.hasClients || _loadingMore || !_hasMore) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 420) {
_loadMore();
}
}
Future<void> _loadInitial({bool forceRefresh = false}) async {
setState(() {
_loading = true;
_error = null;
_page = 1;
_hasMore = true;
});
try {
final items = await _articleService.fetchArticles(
query: _searchController.text,
category: _activeCategory,
page: 1,
pageSize: 12,
forceRefresh: forceRefresh,
);
if (!mounted) return;
setState(() {
_articles
..clear()
..addAll(items.isNotEmpty ? items : widget.articles);
_loading = false;
_hasMore = items.length >= 12;
});
} catch (e) {
if (!mounted) return;
final fallback = _articleService.filterByCategory(
widget.articles,
_activeCategory,
);
setState(() {
_articles
..clear()
..addAll(fallback);
_loading = false;
_error = fallback.isEmpty ? e.toString() : null;
_hasMore = false;
});
}
}
Future<void> _loadMore() async {
setState(() => _loadingMore = true);
try {
final nextPage = _page + 1;
final items = await _articleService.fetchArticles(
query: _searchController.text,
category: _activeCategory,
page: nextPage,
pageSize: 12,
);
if (!mounted) return;
final existing = _articles
.map((item) => (item.url ?? item.title).toLowerCase())
.toSet();
final fresh = items
.where(
(item) =>
!existing.contains((item.url ?? item.title).toLowerCase()),
)
.toList();
setState(() {
_page = nextPage;
_articles.addAll(fresh);
_loadingMore = false;
_hasMore = fresh.isNotEmpty && items.length >= 12;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loadingMore = false;
_hasMore = false;
});
}
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
_loadInitial(forceRefresh: value.trim().isNotEmpty);
});
}
void _changeCategory(String category) {
if (_activeCategory == category) return;
setState(() => _activeCategory = category);
_loadInitial(forceRefresh: true);
}
void _openArticle(NewsArticleModel article) {
setState(() {
_recentlyViewed.removeWhere((item) => item.id == article.id);
_recentlyViewed.insert(0, article);
if (_recentlyViewed.length > 5) _recentlyViewed.removeLast();
});
Navigator.of(context).push(
fadeRoute(ArticleDetailScreen(article: article, related: _articles)),
);
}
Future<void> _shareArticle(NewsArticleModel article) async {
await Clipboard.setData(ClipboardData(text: article.url ?? article.title));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link artikel disalin untuk dibagikan.')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(
centerTitle: true,
title: const Text(
'Artikel Edukasi',
style: TextStyle(fontWeight: FontWeight.w800),
),
backgroundColor: Colors.white,
foregroundColor: SgColors.textPrimary,
elevation: 0.4,
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: () => _loadInitial(forceRefresh: true),
icon: const Icon(LucideIcons.refreshCcw, size: 20),
),
],
),
body: SafeArea(
child: RefreshIndicator(
color: SgColors.primary,
onRefresh: () => _loadInitial(forceRefresh: true),
child: ListView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: const EdgeInsets.fromLTRB(18, 14, 18, 24),
children: [
_SearchField(
controller: _searchController,
onChanged: _onSearchChanged,
onClear: () {
_searchController.clear();
_loadInitial(forceRefresh: true);
},
),
const SizedBox(height: 12),
_CategoryChips(
selected: _activeCategory,
onSelected: _changeCategory,
),
const SizedBox(height: 16),
if (_recentlyViewed.isNotEmpty && !_loading) ...[
_MiniSectionTitle(
title: 'Terakhir Dibaca',
count: _recentlyViewed.length,
),
const SizedBox(height: 10),
_RecentlyViewedList(
items: _recentlyViewed,
onTap: _openArticle,
),
const SizedBox(height: 18),
],
if (_loading)
const _ArticleLoadingList()
else if (_error != null)
EmptyState(
title: 'Artikel gagal dimuat',
message:
'Periksa koneksi internet lalu coba muat ulang artikel online.',
actionLabel: 'Coba Lagi',
onAction: () => _loadInitial(forceRefresh: true),
icon: LucideIcons.fileWarning,
)
else if (_articles.isEmpty)
EmptyState(
title: 'Artikel belum tersedia',
message: 'Coba refresh atau gunakan kata kunci lain.',
actionLabel: 'Refresh',
onAction: () => _loadInitial(forceRefresh: true),
icon: LucideIcons.newspaper,
)
else ...[
_MiniSectionTitle(
title: 'Artikel Populer',
count: _articles.length,
),
const SizedBox(height: 10),
..._articles.map(
(article) => _ArticleListCard(
article: article,
bookmarked: _bookmarked.contains(article.id),
onBookmark: () {
setState(() {
if (!_bookmarked.add(article.id)) {
_bookmarked.remove(article.id);
}
});
},
onShare: () => _shareArticle(article),
onTap: () => _openArticle(article),
),
),
if (_loadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Center(
child: CircularProgressIndicator(strokeWidth: 2.2),
),
),
],
],
),
),
),
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.onChanged,
required this.onClear,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: SgColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 16,
offset: const Offset(0, 7),
),
],
),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
return TextField(
controller: controller,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Cari artikel edukasi...',
hintStyle: AppTypography.body.copyWith(fontSize: 13),
prefixIcon: const Icon(LucideIcons.search, size: 20),
suffixIcon: value.text.isEmpty
? null
: IconButton(
tooltip: 'Hapus pencarian',
onPressed: onClear,
icon: const Icon(LucideIcons.x, size: 18),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 13,
),
),
);
},
),
);
}
}
class _CategoryChips extends StatelessWidget {
const _CategoryChips({required this.selected, required this.onSelected});
final String selected;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: ArticleService.categories.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = ArticleService.categories[index];
final active = category == selected;
return ChoiceChip(
selected: active,
showCheckmark: false,
label: Text(category),
onSelected: (_) => onSelected(category),
selectedColor: SgColors.primary,
backgroundColor: Colors.white,
side: BorderSide(
color: active ? SgColors.primary : SgColors.border,
),
labelStyle: AppTypography.caption.copyWith(
color: active ? Colors.white : SgColors.textSecondary,
fontWeight: FontWeight.w800,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
);
},
),
);
}
}
class _MiniSectionTitle extends StatelessWidget {
const _MiniSectionTitle({required this.title, required this.count});
final String title;
final int count;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Text(title, style: AppTypography.h3)),
Text('$count artikel', style: AppTypography.caption),
],
);
}
}
class _RecentlyViewedList extends StatelessWidget {
const _RecentlyViewedList({required this.items, required this.onTap});
final List<NewsArticleModel> items;
final ValueChanged<NewsArticleModel> onTap;
@override
Widget build(BuildContext context) {
final cardWidth = math.min(
220.0,
math.max(176.0, MediaQuery.sizeOf(context).width * 0.62),
);
return SizedBox(
height: 88,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final item = items[index];
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onTap(item),
child: Container(
width: cardWidth,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SgColors.border),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 58,
height: 58,
child: _ArticleThumb(article: item),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
item.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
);
},
),
);
}
}
class _ArticleListCard extends StatelessWidget {
const _ArticleListCard({
required this.article,
required this.bookmarked,
required this.onBookmark,
required this.onShare,
required this.onTap,
});
final NewsArticleModel article;
final bool bookmarked;
final VoidCallback onBookmark;
final VoidCallback onShare;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return HealthCard(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: AspectRatio(
aspectRatio: 16 / 9,
child: _ArticleThumb(article: article),
),
),
const SizedBox(height: 12),
Row(
children: [
Flexible(
child: StatusBadge(
text: article.category,
color: SgColors.primary,
compact: true,
),
),
const SizedBox(width: 8),
const Spacer(),
_IconAction(
icon: bookmarked
? LucideIcons.bookmarkMinus
: LucideIcons.bookmark,
onTap: onBookmark,
),
const SizedBox(width: 6),
_IconAction(icon: LucideIcons.share2, onTap: onShare),
],
),
const SizedBox(height: 10),
Text(
article.title,
style: AppTypography.h3.copyWith(
color: SgColors.textPrimary,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
article.description,
style: AppTypography.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Row(
children: [
const Icon(
LucideIcons.newspaper,
size: 14,
color: SgColors.primary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
[
if ((article.sourceName ?? '').trim().isNotEmpty)
article.sourceName!,
if (article.createdAt.isNotEmpty) article.createdAt,
].join(''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
),
],
),
],
),
);
}
}
class _IconAction extends StatelessWidget {
const _IconAction({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFFEAF4F3),
borderRadius: BorderRadius.circular(999),
),
child: Icon(icon, size: 17, color: SgColors.primary),
),
);
}
}
class _ArticleThumb extends StatelessWidget {
const _ArticleThumb({required this.article});
final NewsArticleModel article;
@override
Widget build(BuildContext context) {
final image = article.image;
final fallback = _articleAssetByIndex(article.id);
if (image == null || image.trim().isEmpty) {
return Image.asset(fallback, fit: BoxFit.cover);
}
return CachedNetworkImage(
imageUrl: image,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: const Color(0xFFEAF1EF)),
errorWidget: (context, url, error) =>
Image.asset(fallback, fit: BoxFit.cover),
);
}
}
class _ArticleLoadingList extends StatelessWidget {
const _ArticleLoadingList();
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(
4,
(index) => HealthCard(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFEAF1EF),
borderRadius: BorderRadius.circular(18),
),
),
),
const SizedBox(height: 12),
Container(
height: 14,
width: 96,
decoration: BoxDecoration(
color: const Color(0xFFEAF1EF),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 12),
Container(height: 16, color: const Color(0xFFEAF1EF)),
const SizedBox(height: 8),
Container(
height: 16,
width: double.infinity,
color: const Color(0xFFEAF1EF),
),
],
),
),
),
);
}
}
String _articleAssetByIndex(int index) {
const assets = [
'assets/image/onboarding_food.png',
'assets/image/onboarding_monitoring.png',
'assets/image/onboarding_consultation.png',
];
return assets[index % assets.length];
}

View File

@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/auth/widgets/auth_loading_widgets.dart';
import 'package:s_gizi/features/auth/widgets/auth_input_widgets.dart';
import 'package:s_gizi/features/nutritionist/screens/nutritionist_dashboard_screen.dart';
import 'package:s_gizi/features/dashboard/screens/parent_dashboard_screen.dart';
import 'package:s_gizi/features/auth/screens/forgot_password_screen.dart';
import 'package:s_gizi/features/auth/screens/signup_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _api = ApiService();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscure = true;
bool _loading = false;
String _loadingMessage = '';
String? _error;
bool get _phoneValid => isValidIndonesiaPhone(_phoneController.text);
bool get _passwordValid => _passwordController.text.length >= 8;
@override
void dispose() {
_phoneController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_phoneValid || !_passwordValid) {
final message = !_phoneValid
? (_phoneController.text.trim().isEmpty
? 'Nomor telepon wajib diisi'
: 'Nomor telepon tidak valid')
: 'Password minimal 8 karakter.';
setState(() {
_error = message;
});
_showSnack(message);
return;
}
await _guard('Memverifikasi akun...', () async {
await _api.login(
phone: fullIndonesiaPhone(_phoneController.text),
password: _passwordController.text,
);
if (!mounted) return;
final role = (SgiziAppState.instance.role ?? '').trim().toLowerCase();
final isNutritionist =
role == 'nutritionist' || role == 'ahli_gizi' || role == 'ahli gizi';
Navigator.of(context).pushReplacement(
fadeRoute(
isNutritionist
? const NutritionistDashboardScreen()
: const ParentDashboardScreen(),
),
);
});
}
Future<void> _guard(String message, Future<void> Function() action) async {
FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_loadingMessage = message;
_error = null;
});
try {
await action();
} catch (error) {
final message = error.toString().replaceFirst('Exception: ', '');
setState(() => _error = message);
if (mounted) {
_showSnack(message);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
SnackBar _snack(String message) {
return SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
);
}
void _showSnack(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(_snack(message));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
resizeToAvoidBottomInset: true,
body: SafeArea(
child: AuthLoadingOverlay(
visible: _loading,
message: _loadingMessage,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(
SgSpacing.pageH + 4,
SgSpacing.pageV,
SgSpacing.pageH + 4,
20,
),
children: [
const SizedBox(height: 12),
const AppLogo(size: 56, showLabel: true),
const SizedBox(height: 20),
const Text('Masuk ke S-Gizi', style: AppTypography.h1),
const SizedBox(height: 6),
const Text(
'Pantau pertumbuhan dan gizi si kecil dengan mudah.',
style: AppTypography.body,
),
const SizedBox(height: 18),
HealthCard(
dense: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IndonesiaPhoneField(
controller: _phoneController,
enabled: !_loading,
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
onChanged: (_) => setState(() => _error = null),
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: () => setState(() => _obscure = !_obscure),
icon: Icon(
_obscure
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(
color: SgColors.danger,
),
),
],
if (_phoneController.text.trim().isNotEmpty &&
!_phoneValid) ...[
const SizedBox(height: 8),
Text(
'Nomor telepon tidak valid.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_passwordController.text.isNotEmpty &&
!_passwordValid) ...[
const SizedBox(height: 8),
Text(
'Password minimal 8 karakter.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_loading) ...[
const SizedBox(height: 12),
InlineAuthLoading(message: _loadingMessage),
],
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _loading
? null
: () => Navigator.of(
context,
).push(fadeRoute(const ForgotPasswordScreen())),
child: const Text('Lupa Password?'),
),
),
const SizedBox(height: 6),
PrimaryButton(
label: _loading ? 'Memproses...' : 'Masuk',
icon: Icons.login_rounded,
onPressed: _loading ? null : _login,
),
],
),
),
const SizedBox(height: 18),
Wrap(
alignment: WrapAlignment.center,
children: [
Text(
'Belum punya akun? ',
style: AppTypography.body.copyWith(
color: const Color(0xFF62707B),
),
),
InkWell(
onTap: _loading
? null
: () => Navigator.of(
context,
).push(fadeRoute(const SignupScreen())),
child: Text(
'Daftar Sekarang',
style: AppTypography.body.copyWith(
color: SgColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: SgColors.primary,
),
),
),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,578 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/auth/widgets/auth_loading_widgets.dart';
import 'package:s_gizi/features/auth/widgets/auth_input_widgets.dart';
import 'package:s_gizi/features/dashboard/screens/child_empty_state_screen.dart';
import 'package:s_gizi/features/navigation/screens/app_shell.dart';
import 'package:s_gizi/features/nutritionist/screens/nutritionist_dashboard_screen.dart';
enum _ForgotStep { phone, otp, newPassword }
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _api = ApiService();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
_ForgotStep _step = _ForgotStep.phone;
bool _obscurePassword = true;
bool _obscureConfirm = true;
bool _loading = false;
String _loadingMessage = '';
String? _error;
Timer? _timer;
int _secondsLeft = 300;
String _otpCode = '';
int _otpResetTick = 0;
@override
void dispose() {
_phoneController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_secondsLeft = 300;
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_secondsLeft <= 0) {
t.cancel();
if (mounted) setState(() {});
} else {
if (mounted) setState(() => _secondsLeft--);
}
});
}
String get _timerLabel {
final m = _secondsLeft ~/ 60;
final s = _secondsLeft % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _sendOtp() async {
if (!isValidIndonesiaPhone(_phoneController.text)) {
const message = 'Nomor telepon tidak valid';
setState(() => _error = message);
_showSnack(message);
return;
}
setState(() {
_loading = true;
_loadingMessage = 'Mengirim kode OTP...';
_error = null;
});
try {
await _api.forgotPassword(
phone: fullIndonesiaPhone(_phoneController.text),
);
if (!mounted) return;
setState(() => _step = _ForgotStep.otp);
_startTimer();
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
setState(() => _error = msg);
if (mounted) _showSnack(msg);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _resendOtp() async {
setState(() {
_loading = true;
_loadingMessage = 'Mengirim ulang kode OTP...';
_error = null;
_otpCode = '';
_otpResetTick++;
});
try {
await _api.forgotPassword(
phone: fullIndonesiaPhone(_phoneController.text),
);
if (!mounted) return;
_startTimer();
_showSnack('OTP baru telah dikirim ke WhatsApp.');
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
setState(() => _error = msg);
if (mounted) _showSnack(msg);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _verifyOtp() async {
final otp = _otpCode.trim();
if (otp.length < 6) {
const message = 'OTP kurang dari 6 digit';
setState(() => _error = message);
_showSnack(message);
return;
}
FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_loadingMessage = 'Memverifikasi akun...';
_error = null;
});
try {
await _api.verifyForgotPasswordOtp(
phone: fullIndonesiaPhone(_phoneController.text),
otp: otp,
);
if (!mounted) return;
_timer?.cancel();
setState(() {
_step = _ForgotStep.newPassword;
_error = null;
});
_showSnack('OTP berhasil diverifikasi.');
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
if (!mounted) return;
setState(() => _error = _friendlyOtpError(msg));
_showSnack(_friendlyOtpError(msg));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _resetPassword() async {
final password = _passwordController.text;
final confirm = _confirmPasswordController.text;
String? message;
if (password.isEmpty || confirm.isEmpty) {
message = 'Password baru dan konfirmasi wajib diisi.';
} else if (password.length < 8) {
message = 'Password minimal 8 karakter.';
} else if (password != confirm) {
message = 'Konfirmasi password tidak sama.';
}
if (message != null) {
setState(() => _error = message);
_showSnack(message);
return;
}
FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_loadingMessage = 'Menyimpan password baru...';
_error = null;
});
try {
await _api.resetPassword(
phone: fullIndonesiaPhone(_phoneController.text),
otp: _otpCode.trim(),
password: password,
passwordConfirmation: confirm,
);
if (!mounted) return;
setState(() => _loadingMessage = 'Mengecek data anak...');
await _routeAfterPasswordChanged();
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
if (!mounted) return;
final message = _friendlyOtpError(msg);
setState(() => _error = message);
_showSnack(message.isEmpty ? 'Gagal update password.' : message);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _routeAfterPasswordChanged() async {
try {
final role = (SgiziAppState.instance.role ?? '').trim().toLowerCase();
final isNutritionist =
role == 'nutritionist' || role == 'ahli_gizi' || role == 'ahli gizi';
if (isNutritionist) {
if (!mounted) return;
_showSnack('Password berhasil diubah.');
Navigator.of(context).pushAndRemoveUntil(
fadeRoute(const NutritionistDashboardScreen()),
(_) => false,
);
return;
}
try {
final profile = await _api.getProfile();
SgiziAppState.instance.setProfileData(profile);
} catch (_) {}
final children = await _api.getChildren();
final state = SgiziAppState.instance;
state.setChildren(children);
if (children.length == 1) {
state.setActiveChild(children.first.id);
} else if (children.length > 1) {
state.resetActiveChild();
}
if (!mounted) return;
_showSnack('Password berhasil diubah.');
Navigator.of(context).pushAndRemoveUntil(
fadeRoute(
children.isEmpty ? const ChildEmptyStateScreen() : const AppShell(),
),
(_) => false,
);
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
throw Exception(msg.isEmpty ? 'Gagal mengecek data anak.' : msg);
}
}
void _back() {
setState(() {
_error = null;
if (_step == _ForgotStep.otp) {
_step = _ForgotStep.phone;
_timer?.cancel();
_otpCode = '';
_otpResetTick++;
} else if (_step == _ForgotStep.newPassword) {
_step = _ForgotStep.otp;
_passwordController.clear();
_confirmPasswordController.clear();
}
});
}
SnackBar _snack(String message) {
return SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
);
}
void _showSnack(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(_snack(message));
}
String _friendlyOtpError(String message) {
final lower = message.toLowerCase();
if (lower.contains('expired') ||
lower.contains('expire') ||
lower.contains('kadaluarsa') ||
lower.contains('kedaluwarsa')) {
return 'OTP telah kedaluwarsa';
}
if (lower.contains('otp') || lower.contains('kode')) {
return 'OTP yang dimasukkan salah';
}
return message;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor: SgColors.background,
title: const Text('Lupa Password'),
leading: _step == _ForgotStep.phone
? null
: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _loading ? null : _back,
),
),
body: SafeArea(
child: AuthLoadingOverlay(
visible: _loading,
message: _loadingMessage,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
switchInCurve: Curves.easeOut,
transitionBuilder: (child, animation) {
final offset = Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: offset, child: child),
);
},
child: switch (_step) {
_ForgotStep.phone => _buildPhoneStep(),
_ForgotStep.otp => _buildOtpStep(),
_ForgotStep.newPassword => _buildNewPasswordStep(),
},
),
),
),
);
}
Widget _buildPhoneStep() {
return ListView(
key: const ValueKey('forgot-phone'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: [
const Icon(Icons.lock_reset_rounded, size: 48, color: SgColors.primary),
const SizedBox(height: 16),
const Text('Reset Password', style: AppTypography.h1),
const SizedBox(height: 8),
const Text(
'Masukkan nomor telepon terdaftar. Kami akan kirim kode OTP ke WhatsApp.',
style: AppTypography.body,
),
const SizedBox(height: 24),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IndonesiaPhoneField(
controller: _phoneController,
enabled: !_loading,
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(color: SgColors.danger),
),
],
if (_loading) ...[
const SizedBox(height: 14),
InlineAuthLoading(message: _loadingMessage),
],
const SizedBox(height: 20),
PrimaryButton(
label: _loading ? 'Mengirim OTP...' : 'Kirim OTP',
icon: Icons.send_rounded,
onPressed: _loading ? null : _sendOtp,
),
],
),
),
],
);
}
Widget _buildOtpStep() {
final phone = fullIndonesiaPhone(_phoneController.text);
return ListView(
key: const ValueKey('forgot-otp'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: [
const Icon(
Icons.chat_bubble_outline_rounded,
size: 48,
color: SgColors.primary,
),
const SizedBox(height: 16),
const Text('Masukkan Kode OTP', style: AppTypography.h1),
const SizedBox(height: 8),
Text(
'Kode 6 digit telah dikirim ke WhatsApp $phone. Berlaku 5 menit.',
style: AppTypography.body,
),
const SizedBox(height: 24),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OtpCodeInput(
key: ValueKey(_otpResetTick),
enabled: !_loading,
onChanged: (value) => setState(() {
_otpCode = value;
_error = null;
}),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(color: SgColors.danger),
),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_secondsLeft > 0)
Text(
'Kirim ulang dalam $_timerLabel',
style: AppTypography.caption.copyWith(
color: SgColors.textSecondary,
),
)
else
TextButton.icon(
onPressed: _loading ? null : _resendOtp,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Kirim Ulang OTP'),
),
],
),
const SizedBox(height: 16),
PrimaryButton(
label: _loading ? 'Memverifikasi...' : 'Verifikasi OTP',
icon: Icons.arrow_forward_rounded,
onPressed: _loading ? null : _verifyOtp,
),
if (_loading) ...[
const SizedBox(height: 14),
InlineAuthLoading(message: _loadingMessage),
],
],
),
),
],
);
}
Widget _buildNewPasswordStep() {
return ListView(
key: const ValueKey('forgot-new-password'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: [
const Icon(
Icons.lock_outline_rounded,
size: 48,
color: SgColors.primary,
),
const SizedBox(height: 16),
const Text('Buat Password Baru', style: AppTypography.h1),
const SizedBox(height: 8),
const Text(
'Masukkan password baru untuk melanjutkan akses aplikasi.',
style: AppTypography.body,
),
const SizedBox(height: 24),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _passwordController,
enabled: !_loading,
onChanged: (_) => setState(() => _error = null),
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Password Baru',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: _loading
? null
: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
),
const SizedBox(height: 14),
TextField(
controller: _confirmPasswordController,
enabled: !_loading,
onChanged: (_) => setState(() => _error = null),
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _loading ? null : _resetPassword(),
decoration: InputDecoration(
labelText: 'Konfirmasi Password',
prefixIcon: const Icon(Icons.lock_reset_rounded),
suffixIcon: IconButton(
onPressed: _loading
? null
: () => setState(
() => _obscureConfirm = !_obscureConfirm,
),
icon: Icon(
_obscureConfirm
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(color: SgColors.danger),
),
],
if (_passwordController.text.isNotEmpty &&
_passwordController.text.length < 8) ...[
const SizedBox(height: 8),
Text(
'Password minimal 8 karakter.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_confirmPasswordController.text.isNotEmpty &&
_passwordController.text !=
_confirmPasswordController.text) ...[
const SizedBox(height: 8),
Text(
'Konfirmasi password belum cocok.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_loading) ...[
const SizedBox(height: 14),
InlineAuthLoading(message: _loadingMessage),
],
const SizedBox(height: 20),
PrimaryButton(
label: _loading ? 'Menyimpan...' : 'Simpan Password Baru',
icon: Icons.check_circle_outline_rounded,
onPressed: _loading ? null : _resetPassword,
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/models/api_result_model.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/children/screens/result_screen.dart';
class LoadingScreen extends StatefulWidget {
const LoadingScreen({super.key, required this.payload});
final Map<String, dynamic> payload;
@override
State<LoadingScreen> createState() => _LoadingScreenState();
}
class _LoadingScreenState extends State<LoadingScreen> {
final ApiService _apiService = ApiService();
late Future<ApiResultModel> _future;
@override
void initState() {
super.initState();
_future = _apiService.postHasil(widget.payload);
}
void _retry() {
setState(() => _future = _apiService.postHasil(widget.payload));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: FutureBuilder<ApiResultModel>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return ErrorState(
message: _friendlyErrorMessage(snapshot.error),
onRetry: _retry,
);
}
if (snapshot.hasData) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final childId = widget.payload['child_id'];
final tanggalUkur = widget.payload['tanggal_ukur'];
if (childId is int && tanggalUkur is String) {
SgiziAppState.instance.updateChildMeasurementSnapshot(
childId: childId,
latestStatus: snapshot.data!.statusGabungan,
latestMeasurementAt: tanggalUkur,
);
}
if (!mounted) return;
Navigator.of(context).pushReplacement(
fadeRoute(ResultScreen(result: snapshot.data!)),
);
});
}
return const _LoadingContent();
},
),
);
}
String _friendlyErrorMessage(Object? error) {
final raw = error?.toString() ?? '';
if (raw.contains('Data terlalu ekstrem')) {
return 'Data pengukuran berada di luar batas normal WHO. Periksa kembali berat dan tinggi badan, lalu coba hitung lagi.';
}
if (raw.contains('Umur hasil perhitungan')) {
return 'Umur anak harus berada pada rentang 0 sampai 60 bulan untuk perhitungan WHO.';
}
if (raw.contains('Tanggal ukur')) {
return 'Tanggal pengukuran tidak valid. Pastikan tanggal ukur tidak sebelum tanggal lahir.';
}
if (raw.contains('BB/TB') || raw.contains('Berat badan')) {
return 'Data berat badan atau tinggi badan belum valid. Periksa kembali angka yang dimasukkan.';
}
return 'S-Gizi belum berhasil menghitung data. Periksa koneksi atau server API, lalu coba lagi.';
}
}
class _LoadingContent extends StatefulWidget {
const _LoadingContent();
@override
State<_LoadingContent> createState() => _LoadingContentState();
}
class _LoadingContentState extends State<_LoadingContent>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final compactHeight = constraints.maxHeight < 560;
final iconSize = compactHeight ? 76.0 : 104.0;
final outerPadding = compactHeight ? 20.0 : 32.0;
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.all(outerPadding),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: (constraints.maxHeight - outerPadding * 2)
.clamp(0, double.infinity)
.toDouble(),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RotationTransition(
turns: Tween<double>(
begin: 0,
end: 1,
).animate(_controller),
child: Container(
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
color: const Color(0xFFEFF8F7),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: const Color(0xFFE2F1EF),
width: compactHeight ? 5 : 7,
),
boxShadow: [
BoxShadow(
color: SgColors.primary.withValues(alpha: 0.12),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Icon(
Icons.auto_awesome_rounded,
color: SgColors.primary,
size: compactHeight ? 30 : 38,
),
),
),
SizedBox(height: compactHeight ? 18 : 28),
Text(
'Sedang menghitung status gizi...',
style: AppTypography.h1.copyWith(
fontSize: compactHeight ? 20 : 24,
),
textAlign: TextAlign.center,
),
SizedBox(height: compactHeight ? 10 : 14),
const Text(
'Mohon tunggu sebentar, sistem S-Gizi sedang menganalisis data pertumbuhan si Kecil berdasarkan standar kesehatan.',
style: AppTypography.body,
textAlign: TextAlign.center,
),
SizedBox(height: compactHeight ? 22 : 34),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final value = 0.18 + (_controller.value * 0.72);
return LinearProgressIndicator(
value: value,
minHeight: 8,
backgroundColor: const Color(0xFFEFF4F2),
valueColor: const AlwaysStoppedAnimation(
SgColors.primary,
),
);
},
),
),
const SizedBox(height: 12),
AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final percent = (20 + (_controller.value * 70))
.round();
return FittedBox(
child: Text(
'MEMPROSES DATA $percent%',
style: AppTypography.caption.copyWith(
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
),
),
);
},
),
],
),
),
),
),
);
},
),
);
}
}

View File

@ -6,8 +6,8 @@ import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import 'auth_screen.dart'; import 'package:s_gizi/features/auth/screens/auth_screen.dart';
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key}); const OnboardingScreen({super.key});
@ -52,7 +52,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.', 'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.',
floatingTags: [ floatingTags: [
_FloatingTag( _FloatingTag(
label: 'Status Normal', label: 'Status Gizi Baik',
icon: BootstrapIcons.heart, icon: BootstrapIcons.heart,
align: Alignment(0.92, -0.48), align: Alignment(0.92, -0.48),
), ),
@ -154,7 +154,10 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: i == _index gradient: i == _index
? const LinearGradient( ? const LinearGradient(
colors: [SgColors.primaryDark, SgColors.primary], colors: [
SgColors.primaryDark,
SgColors.primary,
],
) )
: null, : null,
color: i == _index ? null : const Color(0xFFCAE8E2), color: i == _index ? null : const Color(0xFFCAE8E2),
@ -232,10 +235,7 @@ class _OnboardingPage extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 20), const SizedBox(height: 20),
_HeroImageWithBadges( _HeroImageWithBadges(imagePath: imagePath, floatingTags: const []),
imagePath: imagePath,
floatingTags: const [],
),
const SizedBox(height: 34), const SizedBox(height: 34),
Text.rich( Text.rich(
_buildTitleSpan(), _buildTitleSpan(),
@ -319,7 +319,7 @@ class _HeroImageWithBadges extends StatelessWidget {
imagePath, imagePath,
fit: BoxFit.cover, fit: BoxFit.cover,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, _, _) => Container(
color: const Color(0xFFE7F4F1), color: const Color(0xFFE7F4F1),
alignment: Alignment.center, alignment: Alignment.center,
child: const Icon( child: const Icon(

View File

@ -0,0 +1,533 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/auth/widgets/auth_loading_widgets.dart';
import 'package:s_gizi/features/auth/widgets/auth_input_widgets.dart';
import 'package:s_gizi/features/children/screens/add_child_screen.dart';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _api = ApiService();
// Step 1 form data
final _nameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
String? _parentGender;
bool _obscurePassword = true;
bool _obscureConfirm = true;
// Step 2 OTP
Timer? _timer;
int _secondsLeft = 300;
String _otpCode = '';
int _otpResetTick = 0;
bool _onOtpStep = false;
bool _loading = false;
String _loadingMessage = '';
String? _error;
@override
void dispose() {
_nameController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_timer?.cancel();
super.dispose();
}
bool get _isFormValid =>
_nameController.text.trim().isNotEmpty &&
isValidIndonesiaPhone(_phoneController.text) &&
_parentGender != null &&
_passwordController.text.length >= 8 &&
_passwordController.text == _confirmPasswordController.text;
void _startTimer() {
_timer?.cancel();
_secondsLeft = 300;
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_secondsLeft <= 0) {
t.cancel();
if (mounted) setState(() {});
} else {
if (mounted) setState(() => _secondsLeft--);
}
});
}
String get _timerLabel {
final m = _secondsLeft ~/ 60;
final s = _secondsLeft % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _sendOtp() async {
if (!_isFormValid) {
final message = !isValidIndonesiaPhone(_phoneController.text)
? 'Nomor telepon tidak valid'
: 'Lengkapi semua data. Password minimal 8 karakter dan konfirmasi harus cocok.';
setState(() {
_error = message;
});
_showSnack(message);
return;
}
setState(() {
_loading = true;
_loadingMessage = 'Mengirim kode OTP...';
_error = null;
});
try {
await _api.registerSendOtp(
name: _nameController.text.trim(),
phone: fullIndonesiaPhone(_phoneController.text),
parentGender: _parentGender!,
password: _passwordController.text,
passwordConfirmation: _confirmPasswordController.text,
);
if (!mounted) return;
setState(() => _onOtpStep = true);
_startTimer();
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
setState(() => _error = msg);
if (mounted) {
_showSnack(msg);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _verifyOtp() async {
final otp = _otp;
if (otp.length < 6) {
const message = 'OTP kurang dari 6 digit';
setState(() => _error = message);
_showSnack(message);
return;
}
setState(() {
_loading = true;
_loadingMessage = 'Memverifikasi akun...';
_error = null;
});
try {
await _api.registerVerifyOtp(
phone: fullIndonesiaPhone(_phoneController.text),
otp: otp,
);
if (!mounted) return;
_timer?.cancel();
ScaffoldMessenger.of(
context,
).showSnackBar(_snack('OTP berhasil diverifikasi. Lengkapi data anak.'));
Navigator.of(context).pushAndRemoveUntil(
fadeRoute(const AddChildScreen(isFirstSetup: true)),
(_) => false,
);
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
setState(() => _error = msg);
if (mounted) {
_showSnack(_friendlyOtpError(msg));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _resendOtp() async {
setState(() {
_loading = true;
_loadingMessage = 'Mengirim ulang kode OTP...';
_error = null;
_otpCode = '';
_otpResetTick++;
});
try {
await _api.registerSendOtp(
name: _nameController.text.trim(),
phone: fullIndonesiaPhone(_phoneController.text),
parentGender: _parentGender!,
password: _passwordController.text,
passwordConfirmation: _confirmPasswordController.text,
);
if (!mounted) return;
_startTimer();
_showSnack('OTP baru telah dikirim ke WhatsApp.');
} catch (e) {
final msg = e.toString().replaceFirst('Exception: ', '');
setState(() => _error = msg);
if (mounted) _showSnack(msg);
} finally {
if (mounted) setState(() => _loading = false);
}
}
String get _otp => _otpCode.trim();
SnackBar _snack(String message) {
return SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
);
}
void _showSnack(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(_snack(message));
}
String _friendlyOtpError(String message) {
final lower = message.toLowerCase();
if (lower.contains('expired') ||
lower.contains('expire') ||
lower.contains('kadaluarsa') ||
lower.contains('kedaluwarsa')) {
return 'OTP telah kedaluwarsa';
}
if (lower.contains('otp') || lower.contains('kode')) {
return 'OTP yang dimasukkan salah';
}
return message;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor: SgColors.background,
title: Text(_onOtpStep ? 'Verifikasi OTP' : 'Registrasi Akun'),
leading: _onOtpStep
? IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _loading
? null
: () {
_timer?.cancel();
setState(() {
_onOtpStep = false;
_error = null;
_otpCode = '';
_otpResetTick++;
});
},
)
: null,
),
body: SafeArea(
child: AuthLoadingOverlay(
visible: _loading,
message: _loadingMessage,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
switchInCurve: Curves.easeOut,
transitionBuilder: (child, animation) {
final offset = Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: offset, child: child),
);
},
child: _onOtpStep ? _buildOtpStep() : _buildFormStep(),
),
),
),
);
}
Widget _buildFormStep() {
return ListView(
key: const ValueKey('signup-form'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
children: [
const Text('Buat Akun Orang Tua', style: AppTypography.h1),
const SizedBox(height: 8),
const Text(
'Daftar untuk mulai memantau tumbuh kembang si kecil.',
style: AppTypography.body,
),
const SizedBox(height: 24),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameController,
onChanged: (_) => setState(() => _error = null),
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
const SizedBox(height: 14),
IndonesiaPhoneField(
controller: _phoneController,
enabled: !_loading,
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: 16),
Text('Gender Orang Tua', style: AppTypography.h3),
const SizedBox(height: 8),
_GenderSegment(
value: _parentGender,
onChanged: (v) => setState(() {
_parentGender = v;
_error = null;
}),
),
const SizedBox(height: 14),
TextField(
controller: _passwordController,
onChanged: (_) => setState(() => _error = null),
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
),
const SizedBox(height: 14),
TextField(
controller: _confirmPasswordController,
onChanged: (_) => setState(() => _error = null),
obscureText: _obscureConfirm,
decoration: InputDecoration(
labelText: 'Konfirmasi Password',
prefixIcon: const Icon(Icons.lock_reset_rounded),
suffixIcon: IconButton(
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
icon: Icon(
_obscureConfirm
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(color: SgColors.danger),
),
],
if (_passwordController.text.isNotEmpty &&
_passwordController.text.length < 8) ...[
const SizedBox(height: 8),
Text(
'Password minimal 8 karakter.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_confirmPasswordController.text.isNotEmpty &&
_passwordController.text !=
_confirmPasswordController.text) ...[
const SizedBox(height: 8),
Text(
'Konfirmasi password belum cocok.',
style: AppTypography.caption.copyWith(
color: SgColors.warning,
),
),
],
if (_loading) ...[
const SizedBox(height: 14),
InlineAuthLoading(message: _loadingMessage),
],
const SizedBox(height: 20),
PrimaryButton(
label: _loading ? 'Mengirim OTP...' : 'Kirim OTP',
icon: Icons.send_rounded,
onPressed: _loading ? null : _sendOtp,
),
],
),
),
],
);
}
Widget _buildOtpStep() {
final phone = fullIndonesiaPhone(_phoneController.text);
return ListView(
key: const ValueKey('signup-otp'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: [
const Icon(
Icons.chat_bubble_outline_rounded,
size: 48,
color: SgColors.primary,
),
const SizedBox(height: 16),
const Text('Masukkan Kode OTP', style: AppTypography.h1),
const SizedBox(height: 8),
Text(
'Kode 6 digit telah dikirim ke WhatsApp $phone. Berlaku 5 menit.',
style: AppTypography.body,
),
const SizedBox(height: 24),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OtpCodeInput(
key: ValueKey(_otpResetTick),
enabled: !_loading,
onChanged: (value) => setState(() {
_otpCode = value;
_error = null;
}),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(color: SgColors.danger),
),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_secondsLeft > 0)
Text(
'Kirim ulang dalam $_timerLabel',
style: AppTypography.caption.copyWith(
color: SgColors.textSecondary,
),
)
else
TextButton.icon(
onPressed: _loading ? null : _resendOtp,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Kirim Ulang OTP'),
),
],
),
const SizedBox(height: 16),
PrimaryButton(
label: _loading ? 'Memverifikasi...' : 'Verifikasi & Daftar',
icon: Icons.how_to_reg_rounded,
onPressed: _loading ? null : _verifyOtp,
),
if (_loading) ...[
const SizedBox(height: 14),
InlineAuthLoading(message: _loadingMessage),
],
],
),
),
],
);
}
}
class _GenderSegment extends StatelessWidget {
const _GenderSegment({required this.value, required this.onChanged});
final String? value;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _GenderChip(
label: 'Ayah',
selected: value == 'ayah',
onTap: () => onChanged('ayah'),
),
),
const SizedBox(width: 10),
Expanded(
child: _GenderChip(
label: 'Bunda',
selected: value == 'bunda',
onTap: () => onChanged('bunda'),
),
),
],
);
}
}
class _GenderChip extends StatelessWidget {
const _GenderChip({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected ? const Color(0xFF0B7A86) : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected ? const Color(0xFF0B7A86) : const Color(0xFFE1E8E6),
),
),
child: Center(
child: Text(
label,
style: AppTypography.body.copyWith(
color: selected ? Colors.white : SgColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}

View File

@ -0,0 +1,139 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/features/nutritionist/screens/nutritionist_dashboard_screen.dart';
import 'package:s_gizi/features/auth/screens/onboarding_screen.dart';
import 'package:s_gizi/features/dashboard/screens/parent_dashboard_screen.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _imageScale;
late final Animation<double> _imageOpacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..forward();
_imageScale = Tween<double>(
begin: 1.22,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_imageOpacity = Tween<double>(
begin: 0.2,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
Timer(const Duration(milliseconds: 1500), _openNextScreen);
}
Future<void> _openNextScreen() async {
await SgiziAppState.instance.restoreSession();
if (!mounted) return;
final state = SgiziAppState.instance;
Widget next = const OnboardingScreen();
if (state.isAuthenticated) {
final role = (state.role ?? '').trim().toLowerCase();
final isNutritionist =
role == 'nutritionist' || role == 'ahli_gizi' || role == 'ahli gizi';
next = isNutritionist
? const NutritionistDashboardScreen()
: const ParentDashboardScreen();
}
Navigator.of(context).pushReplacement(fadeRoute(next));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final shortest = size.shortestSide;
final imageSize = (shortest * 0.58).clamp(190.0, 320.0);
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFDFEFE), Color(0xFFE8F6F3), Color(0xFFF5F7F6)],
),
),
child: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth.clamp(280.0, 520.0),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: (size.width * 0.08).clamp(20.0, 36.0),
),
child: Center(
child: FadeTransition(
opacity: _imageOpacity,
child: ScaleTransition(
scale: _imageScale,
child: Container(
width: imageSize,
height: imageSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: const Color(
0xFF4B8E96,
).withValues(alpha: 0.24),
blurRadius: 42,
spreadRadius: 4,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(40),
child: Image.asset(
'assets/image/Logo_SplashScreen.png',
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorBuilder: (_, _, _) => Image.asset(
'assets/image/logo_sgizi.png',
fit: BoxFit.contain,
),
),
),
),
),
),
),
),
),
);
},
),
),
);
}
}

View File

@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:s_gizi/app_design.dart';
class IndonesiaPhoneFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
while (digits.startsWith('0')) {
digits = digits.substring(1);
}
if (digits.startsWith('62')) {
digits = digits.substring(2);
}
if (digits.length > 13) {
digits = digits.substring(0, 13);
}
return TextEditingValue(
text: digits,
selection: TextSelection.collapsed(offset: digits.length),
);
}
}
String fullIndonesiaPhone(String localNumber) {
final digits = localNumber.replaceAll(RegExp(r'\D'), '');
return '+62$digits';
}
bool isValidIndonesiaPhone(String localNumber) {
final digits = localNumber.replaceAll(RegExp(r'\D'), '');
return RegExp(r'^8\d{8,12}$').hasMatch(digits);
}
class IndonesiaPhoneField extends StatelessWidget {
const IndonesiaPhoneField({
super.key,
required this.controller,
required this.onChanged,
this.enabled = true,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
onChanged: onChanged,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
IndonesiaPhoneFormatter(),
],
decoration: InputDecoration(
labelText: 'Nomor Telepon',
hintText: '8123456789',
prefixIcon: const Icon(Icons.phone_iphone_rounded),
prefixText: '+62 | ',
prefixStyle: AppTypography.body.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w800,
),
),
);
}
}
class OtpCodeInput extends StatefulWidget {
const OtpCodeInput({
super.key,
required this.onChanged,
this.enabled = true,
this.length = 6,
});
final ValueChanged<String> onChanged;
final bool enabled;
final int length;
@override
State<OtpCodeInput> createState() => _OtpCodeInputState();
}
class _OtpCodeInputState extends State<OtpCodeInput> {
late final List<TextEditingController> _controllers;
late final List<FocusNode> _focusNodes;
@override
void initState() {
super.initState();
_controllers = List.generate(widget.length, (_) => TextEditingController());
_focusNodes = List.generate(widget.length, (_) => FocusNode());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.enabled) _focusNodes.first.requestFocus();
});
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
for (final node in _focusNodes) {
node.dispose();
}
super.dispose();
}
void clear() {
for (final controller in _controllers) {
controller.clear();
}
_emit();
}
void _emit() {
widget.onChanged(_controllers.map((c) => c.text).join());
}
void _handleChanged(int index, String value) {
final digits = value.replaceAll(RegExp(r'\D'), '');
if (digits.length > 1) {
for (var i = 0; i < widget.length; i++) {
_controllers[i].text = i < digits.length ? digits[i] : '';
}
final nextIndex = digits.length.clamp(0, widget.length - 1).toInt();
_focusNodes[nextIndex].requestFocus();
_emit();
return;
}
_controllers[index].text = digits;
_controllers[index].selection = TextSelection.collapsed(
offset: digits.length,
);
if (digits.isNotEmpty && index < widget.length - 1) {
_focusNodes[index + 1].requestFocus();
}
_emit();
}
void _handleBackspace(int index) {
if (_controllers[index].text.isEmpty && index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
_emit();
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final gap = constraints.maxWidth < 330 ? 6.0 : 8.0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.length, (index) {
return Expanded(
child: Padding(
padding: EdgeInsets.only(
left: index == 0 ? 0 : gap / 2,
right: index == widget.length - 1 ? 0 : gap / 2,
),
child: Focus(
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
_handleBackspace(index);
}
return KeyEventResult.ignored;
},
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
enabled: widget.enabled,
onChanged: (value) => _handleChanged(index, value),
keyboardType: TextInputType.number,
textInputAction: index == widget.length - 1
? TextInputAction.done
: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(widget.length),
],
textAlign: TextAlign.center,
style: AppTypography.h2.copyWith(
fontWeight: FontWeight.w900,
),
decoration: InputDecoration(
counterText: '',
contentPadding: const EdgeInsets.symmetric(vertical: 14),
filled: true,
fillColor: Colors.white,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: SgColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(
color: SgColors.primary,
width: 1.8,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: SgColors.border),
),
),
),
),
),
);
}),
);
},
);
}
}

View File

@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
class AuthLoadingOverlay extends StatelessWidget {
const AuthLoadingOverlay({
super.key,
required this.visible,
required this.message,
required this.child,
});
final bool visible;
final String message;
final Widget child;
@override
Widget build(BuildContext context) {
return Stack(
children: [
AbsorbPointer(absorbing: visible, child: child),
Positioned.fill(
child: IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: LayoutBuilder(
builder: (context, constraints) {
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
return Container(
color: SgColors.background.withValues(alpha: 0.62),
child: SafeArea(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
20,
20,
20,
20 + bottomInset,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight:
(constraints.maxHeight -
MediaQuery.paddingOf(context).vertical)
.clamp(0, double.infinity)
.toDouble(),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: AuthProgressCard(message: message),
),
),
),
),
),
);
},
),
),
),
),
],
);
}
}
class AuthProgressCard extends StatelessWidget {
const AuthProgressCard({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(end: 1),
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 10 * (1 - value)),
child: Opacity(opacity: value, child: child),
);
},
child: HealthCard(
dense: true,
padding: const EdgeInsets.fromLTRB(18, 18, 18, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: SgColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.health_and_safety_outlined,
color: SgColors.primary,
),
),
const SizedBox(height: 14),
Text(
message,
textAlign: TextAlign.center,
style: AppTypography.h3.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
const _SoftProgressBars(),
],
),
),
);
}
}
class InlineAuthLoading extends StatelessWidget {
const InlineAuthLoading({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEAF8F7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SgColors.primary.withValues(alpha: 0.18)),
),
child: Row(
children: [
const SizedBox(width: 56, child: _SoftProgressBars(compact: true)),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: AppTypography.caption.copyWith(
color: SgColors.primaryDark,
fontWeight: FontWeight.w800,
),
),
),
],
),
);
}
}
class _SoftProgressBars extends StatefulWidget {
const _SoftProgressBars({this.compact = false});
final bool compact;
@override
State<_SoftProgressBars> createState() => _SoftProgressBarsState();
}
class _SoftProgressBarsState extends State<_SoftProgressBars>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final height = widget.compact ? 5.0 : 7.0;
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: (widget.compact ? 56.0 : 128.0);
final gap = widget.compact ? 4.0 : 5.0;
final available = (maxWidth - (gap * 2)).clamp(24.0, maxWidth);
final base = available / (widget.compact ? 5.2 : 4.6);
final extra = available / (widget.compact ? 13.5 : 10.0);
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
final delay = index * 0.18;
final value = ((_controller.value + delay) % 1.0);
final width = base + (value * extra);
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
margin: EdgeInsets.only(right: index == 2 ? 0 : gap),
width: width,
height: height,
decoration: BoxDecoration(
color: SgColors.primary.withValues(
alpha: 0.35 + value * 0.45,
),
borderRadius: BorderRadius.circular(99),
),
);
}),
);
},
);
},
);
}
}

View File

@ -0,0 +1,677 @@
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 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/models/mobile_child_model.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'package:s_gizi/features/navigation/screens/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;
_nameController.addListener(_refreshAvatar);
_shakeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
);
}
@override
void dispose() {
_nameController.removeListener(_refreshAvatar);
_nameController.dispose();
_dateController.dispose();
_shakeController.dispose();
super.dispose();
}
void _refreshAvatar() {
if (mounted) setState(() {});
}
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),
resizeToAvoidBottomInset: true,
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: [
SgAvatar(
name: _nameController.text,
gender: _gender,
radius: 55,
icon: Icons.child_care_rounded,
)
.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(
getInitialName(_nameController.text),
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!), source: 'add_child_birthdate_preview')}',
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,
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,
source: 'add_child_existing_child_card',
),
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}';
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/auth/widgets/auth_loading_widgets.dart';
import 'package:s_gizi/features/navigation/screens/app_shell.dart';
import 'package:s_gizi/features/dashboard/screens/child_empty_state_screen.dart';
class CheckChildScreen extends StatefulWidget {
const CheckChildScreen({super.key});
@override
State<CheckChildScreen> createState() => _CheckChildScreenState();
}
class _CheckChildScreenState extends State<CheckChildScreen> {
final _api = ApiService();
late Future<void> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<void> _load() async {
try {
final profile = await _api.getProfile();
SgiziAppState.instance.setProfileData(profile);
} catch (_) {}
final children = await _api.getChildren();
final state = SgiziAppState.instance;
state.setChildren(children);
if (children.isNotEmpty) {
state.setActiveChild(children.first.id);
state.showFamilyOverview();
}
if (!mounted) return;
Navigator.of(context).pushReplacement(
fadeRoute(
children.isEmpty ? const ChildEmptyStateScreen() : const AppShell(),
),
);
}
void _retry() {
setState(() {
_future = _load();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
body: FutureBuilder<void>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return ErrorState(
message:
'Data anak belum dapat dimuat. Coba ulangi koneksi ke server.',
onRetry: _retry,
);
}
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: (constraints.maxHeight - 48)
.clamp(0, double.infinity)
.toDouble(),
),
child: const Center(
child: AuthProgressCard(message: 'Mengecek data anak...'),
),
),
);
},
);
},
),
);
}
}

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../app_state.dart'; import 'package:s_gizi/app_state.dart';
import 'add_child_screen.dart'; import 'package:s_gizi/features/children/screens/add_child_screen.dart';
class ChildrenScreen extends StatelessWidget { class ChildrenScreen extends StatelessWidget {
const ChildrenScreen({super.key}); const ChildrenScreen({super.key});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,498 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/api_result_model.dart';
import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'package:s_gizi/features/consultation/screens/consultation_chat_screen.dart';
import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
class ResultScreen extends StatefulWidget {
const ResultScreen({super.key, required this.result});
final ApiResultModel result;
@override
State<ResultScreen> createState() => _ResultScreenState();
}
class _ResultScreenState extends State<ResultScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fade;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
)..forward();
_fade = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final result = widget.result;
final measurement = result.measurement;
final visual = nutritionStatusVisual(result.statusGabungan);
final isNormal = normalizeStatus(result.statusGabungan).isNormal;
final hasExtremeZScore =
_isExtremeZScore(result.zScore.bbu) ||
_isExtremeZScore(result.zScore.tbu) ||
_isExtremeZScore(result.zScore.bbtb);
final monitoringLabel = _monitoringStatusLabel(measurement);
final validationLabel = _validationStatusLabel(measurement);
final validationNote = _validationNote(
measurement: measurement,
hasExtremeZScore: hasExtremeZScore,
);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _backToDashboard();
},
child: Scaffold(
backgroundColor: const Color(0xFFEFF8F7),
appBar: AppBar(
title: const Text('Hasil Analisis'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _backToDashboard,
),
),
body: FadeTransition(
opacity: _fade,
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 180),
children: [
HealthCard(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 32,
),
child: Column(
children: [
CircleAvatar(
radius: 32,
backgroundColor: visual.color.withValues(alpha: 0.14),
child: Icon(visual.icon, color: visual.color, size: 38),
),
const SizedBox(height: 24),
Text(
'STATUS GIZI',
style: AppTypography.caption.copyWith(
letterSpacing: 4,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
result.statusGabungan,
style: AppTypography.h1.copyWith(fontSize: 32),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
StatusBadge(text: visual.badgeLabel, color: visual.color),
],
),
),
const SizedBox(height: 20),
HealthCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 25,
backgroundColor: Color(0xFFEAF7F7),
child: Icon(
Icons.child_care_rounded,
color: SgColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
measurement?.childName ?? 'Data Anak',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
const SizedBox(height: 4),
Text(
'${_ageLabel(result.identitas)} | ${_genderLabel(result.identitas.jenisKelamin)}',
style: AppTypography.caption,
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
'TERAKHIR UKUR',
style: AppTypography.caption,
),
const SizedBox(height: 4),
Text(
measurement == null
? 'Hari ini'
: formatMeasurementDate(measurement.tanggalUkur),
style: AppTypography.h3,
),
],
),
],
),
),
if (validationLabel != 'Valid' ||
monitoringLabel != 'Normal' ||
hasExtremeZScore) ...[
const SizedBox(height: 16),
HealthCard(
dense: true,
color: monitoringLabel == 'Perlu Dipantau'
? const Color(0xFFFFF3E0)
: const Color(0xFFFFFBEB),
borderColor: monitoringLabel == 'Perlu Dipantau'
? const Color(0xFFFFCC80)
: const Color(0xFFF4D58A),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.warning_amber_rounded,
color: SgColors.warning,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
monitoringLabel == 'Perlu Dipantau'
? 'Perlu Dipantau'
: validationLabel,
style: AppTypography.h3.copyWith(
color: const Color(0xFF8A5A00),
),
),
const SizedBox(height: 6),
Text(
validationNote,
style: AppTypography.body.copyWith(
color: const Color(0xFF6F5200),
),
),
],
),
),
],
),
),
],
const SizedBox(height: 24),
Row(
children: [
const Icon(
Icons.trending_up_rounded,
color: SgColors.primary,
size: 20,
),
const SizedBox(width: 8),
Text('Detail Indikator Gizi', style: AppTypography.h2),
],
),
const SizedBox(height: 16),
MetricProgress(
label: 'Berat Badan / Umur (BB/U)',
description: 'Mengukur berat terhadap usia',
status: result.kategori.bbu,
value: _scoreToProgress(result.zScore.bbu),
icon: Icons.monitor_weight_outlined,
),
MetricProgress(
label: 'Tinggi Badan / Umur (TB/U)',
description: 'Mengukur tinggi terhadap usia',
status: result.kategori.tbu,
value: _scoreToProgress(result.zScore.tbu),
icon: Icons.straighten_rounded,
),
MetricProgress(
label: 'Berat / Tinggi (${result.identitas.standarBbtb})',
description: 'Proporsi tubuh ideal',
status: result.kategori.bbtb,
value: _scoreToProgress(result.zScore.bbtb),
icon: Icons.verified_outlined,
),
const SizedBox(height: 8),
HealthCard(
color: const Color(0xFFF5FBFA),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: SgColors.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Catatan Nutrisi',
style: AppTypography.h3,
),
const SizedBox(height: 8),
Text(
recommendationStatusExplanation(
result.statusGabungan,
),
style: AppTypography.body,
),
],
),
),
],
),
),
const SizedBox(height: 16),
HealthCard(
dense: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Status Pemantauan', style: AppTypography.h3),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
StatusBadge(
text: monitoringLabel,
color: monitoringLabel == 'Perlu Dipantau'
? SgColors.warning
: SgColors.success,
),
StatusBadge(
text: validationLabel,
color: validationLabel == 'Perlu Ukur Ulang'
? SgColors.warning
: SgColors.success,
),
],
),
if (validationNote.isNotEmpty) ...[
const SizedBox(height: 10),
Text(validationNote, style: AppTypography.body),
],
],
),
),
],
),
),
bottomSheet: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
border: const Border(top: BorderSide(color: SgColors.border)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 16,
offset: const Offset(0, -8),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isNormal) ...[
HealthCard(
color: const Color(0xFFFFFBEB),
borderColor: const Color(0xFFF7E7C1),
child: Row(
children: const [
Icon(
Icons.warning_amber_rounded,
color: SgColors.warning,
),
SizedBox(width: 12),
Expanded(
child: Text(
'Status perlu perhatian. Konsultasi ahli gizi menjadi prioritas.',
style: AppTypography.body,
),
),
],
),
),
const SizedBox(height: 12),
],
if (!isNormal)
PrimaryButton(
label: 'Konsultasi Ahli Gizi',
icon: Icons.chat_bubble_outline_rounded,
onPressed: _startConsultation,
),
if (!isNormal) const SizedBox(height: 12),
PrimaryButton(
label: 'Lihat Rekomendasi Menu',
icon: Icons.restaurant_menu_rounded,
onPressed: () {
Navigator.of(context).push(
fadeRoute(
RecommendationScreen(
status: result.statusGabungan,
childId: measurement?.childId,
riwayatId: measurement?.id,
childName: measurement?.childName,
measuredAt: measurement?.tanggalUkur,
),
),
);
},
),
if (isNormal) ...[
const SizedBox(height: 12),
PrimaryButton(
label: 'Konsultasi Ahli Gizi',
icon: Icons.chat_bubble_outline_rounded,
isOutlined: true,
onPressed: _startConsultation,
),
],
const SizedBox(height: 12),
TextButton(
onPressed: _saveForLater,
child: const Text('Nanti Saja'),
),
],
),
),
),
),
);
}
void _backToDashboard() {
Navigator.of(context).popUntil((route) => route.isFirst);
}
void _startConsultation() {
final measurement = widget.result.measurement;
Navigator.of(context).push(
fadeRoute(
ConsultationChatScreen(
confirmBeforeStart: true,
initialMeasurementId: measurement?.id,
initialMessage: _initialConsultationMessage(),
),
),
);
}
void _saveForLater() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Hasil telah disimpan. Anda dapat berkonsultasi kapan saja melalui halaman hasil atau riwayat pengukuran.',
),
),
);
_backToDashboard();
}
String _initialConsultationMessage() {
final result = widget.result;
final measurement = result.measurement;
return [
'Halo, saya ingin berkonsultasi mengenai hasil status gizi anak.',
if (measurement != null) 'Nama anak: ${measurement.childName}',
'Status BB/U: ${result.kategori.bbu} (${result.zScore.bbu.toStringAsFixed(2)} SD)',
'Status TB/U: ${result.kategori.tbu} (${result.zScore.tbu.toStringAsFixed(2)} SD)',
'Status BB/TB: ${result.kategori.bbtb} (${result.zScore.bbtb.toStringAsFixed(2)} SD)',
'Status pemantauan: ${_monitoringStatusLabel(measurement)}',
if (_validationNote(
measurement: measurement,
hasExtremeZScore: false,
).isNotEmpty)
'Catatan: ${_validationNote(measurement: measurement, hasExtremeZScore: false)}',
].join('\n');
}
double _scoreToProgress(double score) {
if (score.isNaN) return 0.62;
return ((score + 3) / 6).clamp(0.08, 0.96).toDouble();
}
bool _isExtremeZScore(double score) {
if (score.isNaN) return false;
return score < -6 || score > 6;
}
String _validationStatusLabel(AnalysisMeasurementModel? measurement) {
final status =
(measurement?.validationStatus ?? measurement?.dataStatus ?? '')
.toLowerCase();
if (status == 'perlu_ukur_ulang' ||
status == 'anomali' ||
status == 'perlu_verifikasi' ||
measurement?.isAnomaly == true) {
return 'Perlu Ukur Ulang';
}
return 'Valid';
}
String _monitoringStatusLabel(AnalysisMeasurementModel? measurement) {
final status = (measurement?.monitoringStatus ?? '').toLowerCase();
if (status == 'perlu_dipantau') return 'Perlu Dipantau';
return 'Normal';
}
String _validationNote({
required AnalysisMeasurementModel? measurement,
required bool hasExtremeZScore,
}) {
final note = (measurement?.validationNote ?? '').trim();
if (note.isNotEmpty) return note;
if ((measurement?.monitoringStatus ?? '').toLowerCase() ==
'perlu_dipantau') {
return 'Berat badan anak turun signifikan dari pengukuran sebelumnya dan sudah dikonfirmasi oleh orang tua.';
}
if (hasExtremeZScore || measurement?.isAnomaly == true) {
return 'Data pengukuran perlu dicek ulang agar hasil status gizi lebih akurat.';
}
return '';
}
String _ageLabel(IdentitasModel identitas) {
final days = identitas.umurHari;
final months = identitas.umurBulan.isNaN
? '-'
: identitas.umurBulan.toStringAsFixed(2);
if (days == null) return '$months bulan';
return '$months bulan ($days hari)';
}
String _genderLabel(String gender) {
if (gender.toLowerCase().startsWith('p')) return 'Perempuan';
return 'Laki-laki';
}
}

View File

@ -0,0 +1,492 @@
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 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/models/mobile_child_model.dart';
import 'package:s_gizi/models/riwayat_response_model.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'package:s_gizi/utils/parent_greeting_utils.dart';
import 'package:s_gizi/widgets/dashboard_home_header.dart';
import 'package:s_gizi/widgets/growth_chart_card.dart';
import 'package:s_gizi/features/consultation/screens/consultation_chat_screen.dart';
import 'package:s_gizi/features/children/screens/input_screen.dart';
import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
import 'package:s_gizi/features/history/screens/riwayat_screen.dart';
/// Dashboard utama / detail untuk satu anak terpilih.
class ChildDashboardScreen extends StatefulWidget {
const ChildDashboardScreen({
super.key,
this.childId,
this.embedded = false,
this.onChangeTab,
});
final int? childId;
final bool embedded;
final ValueChanged<int>? onChangeTab;
@override
State<ChildDashboardScreen> createState() => _ChildDashboardScreenState();
}
class _ChildDashboardScreenState extends State<ChildDashboardScreen> {
final _api = ApiService();
final _appState = SgiziAppState.instance;
late Future<_ChildDashboardData> _future;
@override
void initState() {
super.initState();
_appState.addListener(_onState);
_future = _load();
}
@override
void dispose() {
_appState.removeListener(_onState);
super.dispose();
}
@override
void didUpdateWidget(covariant ChildDashboardScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.childId != widget.childId) {
setState(() => _future = _load());
}
}
void _onState() => setState(() => _future = _load());
Future<_ChildDashboardData> _load() async {
final id = widget.childId ?? _appState.activeChildId;
if (id == null) {
return const _ChildDashboardData(child: null, history: null);
}
final child = _appState.children.where((c) => c.id == id).firstOrNull;
if (child == null) {
return const _ChildDashboardData(child: null, history: null);
}
final history = await _api.getRiwayat(childId: child.id);
return _ChildDashboardData(child: child, history: history);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
body: SafeArea(
child: FutureBuilder<_ChildDashboardData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: HealthCard(
dense: true,
child: Text('Memuat dashboard anak...'),
),
),
);
}
if (snapshot.hasError) {
return ErrorState(
message: 'Gagal memuat dashboard anak.',
onRetry: () => setState(() => _future = _load()),
);
}
final data = snapshot.data!;
final child = data.child;
final latest = data.latestMeasurement;
if (child == null) {
return const EmptyState(
title: 'Anak Tidak Ditemukan',
message: 'Pilih anak dari dashboard keluarga.',
);
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.embedded) ...[
const DashboardHomeHeader().animate().fadeIn(
duration: 240.ms,
),
const SizedBox(height: 12),
Text(
parentGreetingFromProfile(
_appState.profileData ?? _appState.userData,
),
style: AppTypography.h1.copyWith(fontSize: 32),
).animate().fadeIn(delay: 40.ms),
const SizedBox(height: 6),
Text(
'Pantau pertumbuhan si kecil hari ini.',
style: AppTypography.body,
).animate().fadeIn(delay: 60.ms),
const SizedBox(height: 16),
] else
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(LucideIcons.chevronLeft),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard Anak',
style: AppTypography.caption,
),
Text(
child.nama,
style: AppTypography.h1.copyWith(fontSize: 28),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
).animate().fadeIn(duration: 240.ms),
if (!widget.embedded) ...[
const SizedBox(height: 8),
Text(
formatAgeFromBirthDate(
child.tanggalLahir,
source: 'child_dashboard_header',
),
style: AppTypography.body,
),
],
const SizedBox(height: 16),
_ChildHeroCard(child: child, latest: latest),
const SizedBox(height: 16),
if (data.history != null)
GrowthChartCard(
history: data.history!.riwayat,
onViewDetail: latest == null
? null
: () => Navigator.of(context).push(
fadeRoute(
RiwayatScreen(childId: child.id, initialTab: 1),
),
),
),
const SizedBox(height: 16),
_StatusDetailCard(
latest: latest,
onOpen: latest == null
? () => Navigator.of(
context,
).push(fadeRoute(const InputScreen()))
: () => Navigator.of(
context,
).push(fadeRoute(RiwayatScreen(childId: child.id))),
),
const SizedBox(height: 20),
Text('Aksi Cepat', style: AppTypography.h2),
const SizedBox(height: 12),
_ChildQuickActions(
hasMeasurement: latest != null,
onHitung: () => Navigator.of(
context,
).push(fadeRoute(const InputScreen())),
onRekomendasi: latest == null
? null
: () {
if (widget.onChangeTab != null) {
widget.onChangeTab!(1);
return;
}
Navigator.of(context).push(
fadeRoute(
RecommendationScreen(
childId: child.id,
riwayatId: latest.id,
childName: child.nama,
status: latest.statusGabungan,
measuredAt: latest.tanggalUkur,
),
),
);
},
onRiwayat: () => Navigator.of(
context,
).push(fadeRoute(RiwayatScreen(childId: child.id))),
onKonsultasi: () => Navigator.of(
context,
).push(fadeRoute(const ConsultationChatScreen())),
),
],
),
);
},
),
),
);
}
}
class _ChildDashboardData {
const _ChildDashboardData({required this.child, required this.history});
final MobileChildModel? child;
final RiwayatResponseModel? history;
RiwayatItemModel? get latestMeasurement {
final records = history?.riwayat;
if (records == null || records.isEmpty) return null;
final sorted = [...records]
..sort((a, b) {
final ad = DateTime.tryParse(a.tanggalUkur) ?? DateTime(2000);
final bd = DateTime.tryParse(b.tanggalUkur) ?? DateTime(2000);
final dateCompare = ad.compareTo(bd);
if (dateCompare != 0) return dateCompare;
return a.id.compareTo(b.id);
});
return sorted.last;
}
}
class _ChildHeroCard extends StatelessWidget {
const _ChildHeroCard({required this.child, required this.latest});
final MobileChildModel child;
final RiwayatItemModel? latest;
@override
Widget build(BuildContext context) {
final visual = nutritionStatusVisual(
latest?.statusGabungan ?? child.latestStatus ?? 'Belum Diukur',
);
return HealthCard(
child: Row(
children: [
ChildAvatar(name: child.nama, gender: child.jenisKelamin, radius: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(child.nama, style: AppTypography.h2),
Text(
formatAgeFromBirthDate(
child.tanggalLahir,
source: 'child_dashboard_hero_card',
),
style: AppTypography.body,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(99),
),
child: Text(
visual.badgeLabel,
style: AppTypography.caption.copyWith(
color: visual.color,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
],
),
);
}
}
class _StatusDetailCard extends StatelessWidget {
const _StatusDetailCard({required this.latest, required this.onOpen});
final RiwayatItemModel? latest;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
if (latest == null) {
return HealthCard(
dense: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircleAvatar(
radius: 28,
backgroundColor: Color(0xFFEAF8F7),
child: Icon(LucideIcons.activity, color: SgColors.primary),
),
const SizedBox(height: 12),
Text(
'Belum ada pengukuran tersimpan',
style: AppTypography.h2.copyWith(fontSize: 17),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
const Text(
'Yuk mulai pantau pertumbuhan anak dengan pengukuran pertama.',
style: AppTypography.body,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
PrimaryButton(
label: 'Tambah Pengukuran Pertama',
icon: PhosphorIconsBold.calculator,
onPressed: onOpen,
),
],
),
);
}
final normalized = normalizeStatus(latest!.statusGabungan);
return HealthCard(
color: const Color(0xFFE8F7F1),
borderColor: const Color(0xFF0B7A86),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Status Gizi Terakhir', style: AppTypography.h3),
const SizedBox(height: 8),
Text(
normalized.primaryCategory,
style: AppTypography.h1.copyWith(
fontSize: 26,
color: Colors.black87,
),
),
const SizedBox(height: 6),
Text(
normalized.focusSummary,
style: AppTypography.body.copyWith(color: Colors.black87),
),
const SizedBox(height: 10),
Text(
'Terakhir diperiksa: ${formatMeasurementDate(latest!.tanggalUkur)}',
style: AppTypography.caption,
),
const SizedBox(height: 12),
PrimaryButton(
label: 'Lihat Detail Analisis',
icon: LucideIcons.arrowRight,
onPressed: onOpen,
),
],
),
);
}
}
class _ChildQuickActions extends StatelessWidget {
const _ChildQuickActions({
required this.hasMeasurement,
required this.onHitung,
required this.onRekomendasi,
required this.onRiwayat,
required this.onKonsultasi,
});
final bool hasMeasurement;
final VoidCallback onHitung;
final VoidCallback? onRekomendasi;
final VoidCallback onRiwayat;
final VoidCallback onKonsultasi;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _ActionChip(
icon: PhosphorIconsBold.calculator,
label: 'Hitung Gizi',
onTap: onHitung,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionChip(
icon: LucideIcons.apple,
label: 'Nutrisi',
onTap: onRekomendasi ?? () {},
enabled: onRekomendasi != null,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionChip(
icon: LucideIcons.messageCircle,
label: 'Konsultasi',
onTap: onKonsultasi,
),
),
],
);
}
}
class _ActionChip extends StatelessWidget {
const _ActionChip({
required this.icon,
required this.label,
required this.onTap,
this.enabled = true,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: enabled ? 1 : 0.45,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE1E8E6)),
),
child: Column(
children: [
Icon(icon, color: const Color(0xFF0B7A86), size: 22),
const SizedBox(height: 6),
Text(
label,
style: AppTypography.caption.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/features/children/screens/add_child_screen.dart';
class ChildEmptyStateScreen extends StatelessWidget {
const ChildEmptyStateScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(SgSpacing.pageH + 4),
child: Column(
children: [
const Spacer(),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/image/onboarding_monitoring.png',
width: 140,
height: 140,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: const Color(0xFFE8F7F1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
LucideIcons.baby,
size: 56,
color: Color(0xFF0B7A86),
),
),
),
)
.animate()
.fadeIn(duration: 320.ms)
.scale(begin: const Offset(0.92, 0.92)),
const SizedBox(height: 20),
Text(
'Belum ada data anak',
textAlign: TextAlign.center,
style: AppTypography.h1.copyWith(fontSize: 24),
).animate().fadeIn(delay: 80.ms),
const SizedBox(height: 8),
const Text(
'Tambahkan data si kecil untuk mulai memantau pertumbuhan dan status gizinya.',
textAlign: TextAlign.center,
style: AppTypography.body,
).animate().fadeIn(delay: 140.ms),
const Spacer(),
PrimaryButton(
label: 'Tambah Data Anak',
icon: Icons.add_rounded,
onPressed: () {
Navigator.of(context).pushReplacement(
fadeRoute(const AddChildScreen(isFirstSetup: true)),
);
},
).animate().fadeIn(delay: 200.ms).slideY(begin: 0.08, end: 0),
],
),
),
),
);
}
}

View File

@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/models/mobile_child_model.dart';
import 'package:s_gizi/models/riwayat_response_model.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/utils/dashboard_error_utils.dart';
import 'package:s_gizi/utils/parent_greeting_utils.dart';
import 'package:s_gizi/widgets/dashboard_home_header.dart';
import 'package:s_gizi/widgets/family_child_overview_card.dart';
import 'package:s_gizi/widgets/family_dashboard_skeleton.dart';
import 'package:s_gizi/features/children/screens/add_child_screen.dart';
import 'package:s_gizi/features/dashboard/screens/main_dashboard_screen.dart';
/// Overview pertumbuhan seluruh anak (hanya jika > 1 anak).
class FamilyDashboardScreen extends StatefulWidget {
const FamilyDashboardScreen({
super.key,
required this.onChangeTab,
this.onOpenDashboard,
});
final ValueChanged<int> onChangeTab;
final ValueChanged<MobileChildModel>? onOpenDashboard;
@override
State<FamilyDashboardScreen> createState() => _FamilyDashboardScreenState();
}
class _FamilyDashboardScreenState extends State<FamilyDashboardScreen> {
final _api = ApiService();
final _appState = SgiziAppState.instance;
late Future<_FamilyDashboardData> _future;
@override
void initState() {
super.initState();
_appState.addListener(_onState);
_future = _load();
}
@override
void dispose() {
_appState.removeListener(_onState);
super.dispose();
}
void _onState() => setState(() => _future = _load());
Future<_FamilyDashboardData> _load() async {
final children = _appState.children;
final histories = <int, RiwayatResponseModel>{};
Object? firstError;
var failedCount = 0;
await Future.wait(
children.map((child) async {
try {
histories[child.id] = await _api.getRiwayat(childId: child.id);
} catch (error) {
firstError ??= error;
failedCount++;
}
}),
);
if (children.isNotEmpty && failedCount == children.length) {
throw firstError ?? Exception('Gagal memuat dashboard keluarga.');
}
return _FamilyDashboardData(children: children, histories: histories);
}
void _openChildDashboard(MobileChildModel child) {
_appState.setActiveChild(child.id);
if (widget.onOpenDashboard != null) {
widget.onOpenDashboard!(child);
return;
}
Navigator.of(
context,
).push(fadeRoute(MainDashboardScreen(onChangeTab: widget.onChangeTab)));
}
void _openAddChild() {
Navigator.of(context).push(fadeRoute(const AddChildScreen()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
floatingActionButton: FloatingActionButton.extended(
onPressed: _openAddChild,
backgroundColor: SgColors.primaryTeal,
elevation: 4,
icon: const Icon(Icons.add_rounded, color: Colors.white, size: 20),
label: const Text(
'Tambah Anak',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
),
body: SafeArea(
child: FutureBuilder<_FamilyDashboardData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const FamilyDashboardSkeleton();
}
if (snapshot.hasError) {
final info = dashboardErrorInfo(snapshot.error);
return ErrorState(
title: info.title,
message: info.message,
icon: info.icon,
color: info.color,
onRetry: () => setState(() => _future = _load()),
);
}
final data = snapshot.data!;
final children = data.children;
return RefreshIndicator(
color: SgColors.primary,
onRefresh: () async {
final refreshed = await _api.getChildren();
_appState.setChildren(refreshed);
setState(() => _future = _load());
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
SgSpacing.pageH,
SgSpacing.pageV,
SgSpacing.pageH,
88,
),
children: [
const DashboardHomeHeader().animate().fadeIn(
duration: 240.ms,
),
const SizedBox(height: SgSpacing.item),
Text(
parentGreetingFromProfile(
_appState.profileData ?? _appState.userData,
),
style: AppTypography.h1.copyWith(fontSize: 26),
).animate().fadeIn(delay: 30.ms),
const SizedBox(height: 4),
Text(
'Pantau pertumbuhan si kecil hari ini.',
style: AppTypography.body.copyWith(fontSize: 13),
).animate().fadeIn(delay: 50.ms),
const SizedBox(height: SgSpacing.section),
Text('Anak Anda', style: AppTypography.h2),
const SizedBox(height: SgSpacing.item),
...children.asMap().entries.map((entry) {
final index = entry.key;
final child = entry.value;
final history =
data.histories[child.id]?.riwayat ?? const [];
final latest = data.latestFor(child.id);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: FamilyChildOverviewCard(
child: child,
latest: latest,
history: history,
index: index,
onTap: () => _openChildDashboard(child),
),
);
}),
],
),
);
},
),
),
);
}
}
class _FamilyDashboardData {
_FamilyDashboardData({required this.children, required this.histories});
final List<MobileChildModel> children;
final Map<int, RiwayatResponseModel> histories;
RiwayatItemModel? latestFor(int childId) {
final records = histories[childId]?.riwayat;
if (records == null || records.isEmpty) return null;
final sorted = [...records]
..sort((a, b) {
final bd = DateTime.tryParse(b.tanggalUkur) ?? DateTime(2000);
final ad = DateTime.tryParse(a.tanggalUkur) ?? DateTime(2000);
return bd.compareTo(ad);
});
return sorted.first;
}
}

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/features/dashboard/screens/family_dashboard_screen.dart';
import 'package:s_gizi/features/dashboard/screens/main_dashboard_screen.dart';
/// Tab Home: 1 anak dashboard utama; 2+ anak overview keluarga.
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.onChangeTab,
required this.onOverviewChanged,
});
final ValueChanged<int> onChangeTab;
final ValueChanged<bool> onOverviewChanged;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _appState = SgiziAppState.instance;
@override
void initState() {
super.initState();
_appState.addListener(_rebuild);
}
@override
void dispose() {
_appState.removeListener(_rebuild);
super.dispose();
}
void _rebuild() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final children = _appState.children;
if (children.isNotEmpty && _appState.showFamilyOverviewOnHome) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOverviewChanged(true);
});
return FamilyDashboardScreen(
onChangeTab: widget.onChangeTab,
onOpenDashboard: (child) {
_appState.setActiveChild(child.id);
},
);
}
if (children.length <= 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOverviewChanged(false);
});
final child =
_appState.activeChild ??
(children.isNotEmpty ? children.first : null);
if (child != null && _appState.activeChildId != child.id) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_appState.setActiveChild(child.id);
});
}
return MainDashboardScreen(
onChangeTab: widget.onChangeTab,
onShowFamilyOverview: () => widget.onOverviewChanged(true),
);
}
if (_appState.showFamilyOverviewOnHome || _appState.activeChild == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOverviewChanged(true);
});
return FamilyDashboardScreen(
onChangeTab: widget.onChangeTab,
onOpenDashboard: (child) {
_appState.setActiveChild(child.id);
},
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOverviewChanged(false);
});
return MainDashboardScreen(
onChangeTab: widget.onChangeTab,
onShowFamilyOverview: () => widget.onOverviewChanged(true),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/features/children/screens/check_child_screen.dart';
class ParentDashboardScreen extends StatelessWidget {
const ParentDashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return const CheckChildScreen();
}
}

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../models/riwayat_response_model.dart'; import 'package:s_gizi/models/riwayat_response_model.dart';
import '../utils/nutrition_display_utils.dart'; import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'consultation_chat_screen.dart'; import 'package:s_gizi/features/consultation/screens/consultation_chat_screen.dart';
import 'recommendation_screen.dart'; import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
class RiwayatDetailScreen extends StatelessWidget { class RiwayatDetailScreen extends StatelessWidget {
const RiwayatDetailScreen({ const RiwayatDetailScreen({
@ -66,7 +66,7 @@ class RiwayatDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Status Gizi: ${item.statusGabungan}', 'Status Gizi: ${localizeNutritionStatus(item.statusGabungan)}',
style: AppTypography.h2, style: AppTypography.h2,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -87,7 +87,6 @@ class RiwayatDetailScreen extends StatelessWidget {
ChildAvatar( ChildAvatar(
name: child.nama, name: child.nama,
gender: child.jenisKelamin, gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 28, radius: 28,
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
@ -103,7 +102,11 @@ class RiwayatDetailScreen extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
formatAgeFromMonths(item.umurBulan), formatAgeAtMeasurement(
birthDate: child.tanggalLahir,
measurementDate: item.tanggalUkur,
source: 'riwayat_detail_header',
),
style: AppTypography.caption, style: AppTypography.caption,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@ -124,8 +127,14 @@ class RiwayatDetailScreen extends StatelessWidget {
title: 'Data Pengukuran', title: 'Data Pengukuran',
child: Column( child: Column(
children: [ children: [
_InfoRow(label: 'Berat', value: '${item.berat.toStringAsFixed(1)} kg'), _InfoRow(
_InfoRow(label: 'Tinggi', value: '${item.tinggi.toStringAsFixed(1)} cm'), label: 'Berat',
value: '${item.berat.toStringAsFixed(1)} kg',
),
_InfoRow(
label: 'Tinggi',
value: '${item.tinggi.toStringAsFixed(1)} cm',
),
_InfoRow( _InfoRow(
label: 'Cara', label: 'Cara',
value: measurementMethodLabel(item.caraUkur, item.umurBulan), value: measurementMethodLabel(item.caraUkur, item.umurBulan),
@ -293,7 +302,7 @@ class _ScoreRow extends StatelessWidget {
SizedBox(width: 64, child: Text(label, style: AppTypography.body)), SizedBox(width: 64, child: Text(label, style: AppTypography.body)),
Expanded( Expanded(
child: Text( child: Text(
'$score -> $category', '$score ${localizeNutritionStatus(category)}',
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: AppTypography.h3, style: AppTypography.h3,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,288 @@
import 'dart:async';
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 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/consultation/screens/consultation_chat_screen.dart';
import 'package:s_gizi/features/dashboard/screens/home_screen.dart';
import 'package:s_gizi/features/nutrition/screens/nutrition_screen.dart';
import 'package:s_gizi/features/profile/screens/profile_screen.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final _api = ApiService();
int _index = 0;
bool _homeShowsFamilyOverview = false;
int _consultationUnread = 0;
Timer? _badgeTimer;
late final List<Widget> _screens = [
HomeScreen(
onChangeTab: _setTab,
onOverviewChanged: _setHomeOverviewVisible,
),
const NutritionScreen(),
const ConsultationChatScreen(showAppBar: false),
const ProfileScreen(),
];
void _setTab(int index) {
setState(() => _index = index);
_loadConsultationUnread();
}
@override
void initState() {
super.initState();
SgiziAppState.instance.addListener(_loadConsultationUnread);
_loadConsultationUnread();
_badgeTimer = Timer.periodic(
const Duration(seconds: 8),
(_) => _loadConsultationUnread(),
);
}
@override
void dispose() {
_badgeTimer?.cancel();
SgiziAppState.instance.removeListener(_loadConsultationUnread);
super.dispose();
}
Future<void> _loadConsultationUnread() async {
final child = SgiziAppState.instance.activeChild;
if (child == null) {
if (mounted) setState(() => _consultationUnread = 0);
return;
}
try {
final rows = await _api.getConsultationRooms(childId: child.id);
final total = rows.fold<int>(
0,
(sum, row) => sum + ((row['unread_count'] as num?)?.toInt() ?? 0),
);
if (mounted) setState(() => _consultationUnread = total);
} catch (_) {
if (mounted) setState(() => _consultationUnread = 0);
}
}
void _setHomeOverviewVisible(bool visible) {
if (_homeShowsFamilyOverview == visible) return;
setState(() => _homeShowsFamilyOverview = visible);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
child: KeyedSubtree(key: ValueKey(_index), child: _screens[_index]),
),
bottomNavigationBar: _index == 0 && _homeShowsFamilyOverview
? null
: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, -6),
),
],
),
child: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(10, 6, 10, 8),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 380;
return Row(
children: [
_NavItem(
label: 'Home',
active: _index == 0,
icon: PhosphorIconsRegular.house,
compact: compact,
onTap: () => _setTab(0),
),
_NavItem(
label: 'Nutrisi',
active: _index == 1,
icon: LucideIcons.apple,
compact: compact,
onTap: () => _setTab(1),
),
_NavItem(
label: 'Konsultasi',
active: _index == 2,
icon: PhosphorIconsRegular.chatCircleDots,
badge: _consultationUnread,
compact: compact,
onTap: () => _setTab(2),
),
_NavItem(
label: 'Profil',
active: _index == 3,
icon: PhosphorIconsRegular.user,
compact: compact,
onTap: () => _setTab(3),
),
],
);
},
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.label,
required this.active,
required this.icon,
required this.compact,
required this.onTap,
this.badge = 0,
});
final String label;
final bool active;
final IconData icon;
final bool compact;
final VoidCallback onTap;
final int badge;
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child:
AnimatedContainer(
duration: const Duration(milliseconds: 220),
padding: EdgeInsets.symmetric(vertical: compact ? 6 : 8),
decoration: BoxDecoration(
color: active
? const Color(0xFFEAF8F7)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
AnimatedScale(
scale: active ? 1.08 : 1,
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
size: compact ? 21 : 24,
color: active
? const Color(0xFF0B7A86)
: const Color(0xFF8B959C),
),
),
if (badge > 0)
Positioned(
right: -10,
top: -7,
child: _NavBadge(count: badge),
),
],
),
if (badge > 0 && !compact) ...[
const SizedBox(height: 2),
Text(
badge > 99 ? '99+' : '$badge',
style: AppTypography.caption.copyWith(
fontSize: 9,
color: SgColors.danger,
fontWeight: FontWeight.w900,
),
),
],
const SizedBox(height: 3),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
fontSize: compact ? 10 : null,
color: active
? const Color(0xFF0B7A86)
: const Color(0xFF8B959C),
fontWeight: active
? FontWeight.w800
: FontWeight.w600,
),
),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
height: 3,
width: active ? 20 : 0,
decoration: BoxDecoration(
color: const Color(0xFF0B7A86),
borderRadius: BorderRadius.circular(99),
),
),
],
),
)
.animate(target: active ? 1 : 0)
.scale(
begin: const Offset(0.98, 0.98),
end: const Offset(1, 1),
duration: 220.ms,
),
),
);
}
}
class _NavBadge extends StatelessWidget {
const _NavBadge({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minWidth: 17, minHeight: 17),
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
color: SgColors.danger,
borderRadius: BorderRadius.circular(99),
border: Border.all(color: Colors.white, width: 2),
),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w900,
),
),
);
}
}

View File

@ -5,18 +5,19 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../app_state.dart'; import 'package:s_gizi/app_state.dart';
import '../models/api_result_model.dart'; import 'package:s_gizi/models/api_result_model.dart';
import '../models/mobile_child_model.dart'; import 'package:s_gizi/models/mobile_child_model.dart';
import '../models/news_article_model.dart'; import 'package:s_gizi/models/news_article_model.dart';
import '../models/riwayat_response_model.dart'; import 'package:s_gizi/models/riwayat_response_model.dart';
import '../services/api_service.dart'; import 'package:s_gizi/services/api_service.dart';
import '../utils/nutrition_display_utils.dart'; import 'package:s_gizi/services/article_service.dart';
import 'article_detail_screen.dart'; import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'articles_screen.dart'; import 'package:s_gizi/features/articles/screens/article_detail_screen.dart';
import 'recommendation_screen.dart'; import 'package:s_gizi/features/articles/screens/articles_screen.dart';
import 'riwayat_screen.dart'; import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
import 'package:s_gizi/features/history/screens/riwayat_screen.dart';
class NutritionScreen extends StatefulWidget { class NutritionScreen extends StatefulWidget {
const NutritionScreen({super.key}); const NutritionScreen({super.key});
@ -27,6 +28,7 @@ class NutritionScreen extends StatefulWidget {
class _NutritionScreenState extends State<NutritionScreen> { class _NutritionScreenState extends State<NutritionScreen> {
final ApiService _apiService = ApiService(); final ApiService _apiService = ApiService();
final ArticleService _articleService = ArticleService();
final SgiziAppState _appState = SgiziAppState.instance; final SgiziAppState _appState = SgiziAppState.instance;
late Future<_NutritionViewData> _future; late Future<_NutritionViewData> _future;
String _activeCategory = 'Semua'; String _activeCategory = 'Semua';
@ -56,9 +58,14 @@ class _NutritionScreenState extends State<NutritionScreen> {
return const _NutritionViewData.noActiveChild(); return const _NutritionViewData.noActiveChild();
} }
final history = await _apiService.getRiwayat(childId: child.id); final results = await Future.wait([
final articles = await _apiService.getArticlesDb(); _apiService.getRiwayat(childId: child.id),
final sorted = [...history.riwayat]..sort((a, b) { _articleService.fetchArticles(forceRefresh: true),
]);
final history = results[0] as RiwayatResponseModel;
final articles = results[1] as List<NewsArticleModel>;
final sorted = [...history.riwayat]
..sort((a, b) {
final bDate = DateTime.tryParse(b.tanggalUkur); final bDate = DateTime.tryParse(b.tanggalUkur);
final aDate = DateTime.tryParse(a.tanggalUkur); final aDate = DateTime.tryParse(a.tanggalUkur);
if (aDate == null && bDate == null) return 0; if (aDate == null && bDate == null) return 0;
@ -127,14 +134,27 @@ class _NutritionScreenState extends State<NutritionScreen> {
); );
} }
return SingleChildScrollView( return RefreshIndicator(
physics: const BouncingScrollPhysics(), color: SgColors.primary,
onRefresh: () async {
_retry();
await _future;
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const _NutritionHeader(), const _NutritionHeader(),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), padding: const EdgeInsets.fromLTRB(
SgSpacing.pageH,
SgSpacing.section,
SgSpacing.pageH,
20,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -169,6 +189,7 @@ class _NutritionScreenState extends State<NutritionScreen> {
), ),
], ],
), ),
),
); );
}, },
), ),
@ -210,9 +231,7 @@ class _NutritionHeader extends StatelessWidget {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(bottom: Radius.circular(32)),
bottom: Radius.circular(32),
),
), ),
child: Stack( child: Stack(
children: [ children: [
@ -291,10 +310,7 @@ class _FloatingCircle extends StatelessWidget {
return Container( return Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(shape: BoxShape.circle, color: color),
shape: BoxShape.circle,
color: color,
),
); );
} }
} }
@ -317,13 +333,14 @@ class _AgeGuideSection extends StatelessWidget {
children: [ children: [
Text( Text(
'Panduan Usia', 'Panduan Usia',
style: AppTypography.h2.copyWith( style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
color: SgColors.textPrimary,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _NutriColors.secondary.withValues(alpha: 0.18), color: _NutriColors.secondary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@ -337,10 +354,7 @@ class _AgeGuideSection extends StatelessWidget {
color: _NutriColors.primary, color: _NutriColors.primary,
), ),
SizedBox(width: 6), SizedBox(width: 6),
Text( Text('05 Tahun', style: AppTypography.caption),
'05 Tahun',
style: AppTypography.caption,
),
], ],
), ),
), ),
@ -362,10 +376,7 @@ class _AgeGuideSection extends StatelessWidget {
itemBuilder: (context, index, realIndex) { itemBuilder: (context, index, realIndex) {
final guide = _ageGuides[index]; final guide = _ageGuides[index];
final isActive = index == activeIndex; final isActive = index == activeIndex;
return _AgeGuideCard( return _AgeGuideCard(guide: guide, isActive: isActive);
guide: guide,
isActive: isActive,
);
}, },
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -424,7 +435,7 @@ class _CategoryChipsRow extends StatelessWidget {
onTap: () => onCategorySelected(label), onTap: () => onCategorySelected(label),
); );
}, },
separatorBuilder: (_, __) => const SizedBox(width: 8), separatorBuilder: (context, index) => const SizedBox(width: 8),
itemCount: _nutritionCategories.length, itemCount: _nutritionCategories.length,
), ),
), ),
@ -457,8 +468,7 @@ class _ArticleSection extends StatelessWidget {
) )
.toList(); .toList();
final isAll = activeCategory == 'Semua'; final visible = articles.take(5).toList();
final visible = isAll ? articles.take(3).toList() : articles;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -473,9 +483,10 @@ class _ArticleSection extends StatelessWidget {
color: _NutriColors.primary, color: _NutriColors.primary,
), ),
const Spacer(), const Spacer(),
if (isAll && articles.length > 3)
TextButton( TextButton(
onPressed: () { onPressed: data.articles.isEmpty
? null
: () {
Navigator.of(context).push( Navigator.of(context).push(
fadeRoute( fadeRoute(
ArticlesScreen( ArticlesScreen(
@ -537,18 +548,18 @@ class _LatestStatusSection extends StatelessWidget {
'Belum ada riwayat pengukuran untuk anak aktif. Tambahkan pengukuran untuk menampilkan status terbaru.', 'Belum ada riwayat pengukuran untuk anak aktif. Tambahkan pengukuran untuk menampilkan status terbaru.',
actionLabel: 'Buka Riwayat', actionLabel: 'Buka Riwayat',
onAction: () { onAction: () {
Navigator.of(context).push( Navigator.of(
fadeRoute(RiwayatScreen(childId: data.child!.id)), context,
); ).push(fadeRoute(RiwayatScreen(childId: data.child!.id)));
}, },
) )
else else
_LatestNutritionStatusCard( _LatestNutritionStatusCard(
child: data.child!, child: data.child!,
latest: data.latestMeasurement!, latest: data.latestMeasurement!,
onOpenHistory: () => Navigator.of(context).push( onOpenHistory: () => Navigator.of(
fadeRoute(RiwayatScreen(childId: data.child!.id)), context,
), ).push(fadeRoute(RiwayatScreen(childId: data.child!.id))),
), ),
], ],
); );
@ -634,7 +645,7 @@ class _FoodRecommendationSection extends StatelessWidget {
final item = items[index]; final item = items[index];
return _FoodPreviewCard( return _FoodPreviewCard(
item: item, item: item,
assetImage: _foodAssetByIndex(index), fallbackAssetImage: _foodAssetByIndex(index),
); );
}, },
), ),
@ -714,50 +725,6 @@ class _CategoryChip extends StatelessWidget {
} }
} }
class _EducationCard extends StatelessWidget {
const _EducationCard({required this.item});
final _EducationItem item;
@override
Widget build(BuildContext context) {
return HealthCard(
margin: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFE9F6F2),
child: Icon(item.icon, color: SgColors.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: AppTypography.h3,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
item.description,
style: AppTypography.body,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
class _AgeGuideCard extends StatelessWidget { class _AgeGuideCard extends StatelessWidget {
const _AgeGuideCard({required this.guide, required this.isActive}); const _AgeGuideCard({required this.guide, required this.isActive});
@ -790,8 +757,10 @@ class _AgeGuideCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 10, vertical: 8), horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _NutriColors.secondary.withValues(alpha: 0.25), color: _NutriColors.secondary.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@ -799,11 +768,7 @@ class _AgeGuideCard extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(guide.icon, color: _NutriColors.primary, size: 18),
guide.icon,
color: _NutriColors.primary,
size: 18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
guide.title, guide.title,
@ -837,11 +802,12 @@ class _AgeGuideCard extends StatelessWidget {
const List<String> _nutritionCategories = [ const List<String> _nutritionCategories = [
'Semua', 'Semua',
'Stunting', 'Gizi',
'MPASI', 'MPASI',
'Stunting',
'Tumbuh Kembang',
'Protein', 'Protein',
'Vitamin', 'Vitamin',
'Gizi Seimbang',
]; ];
class _LatestNutritionStatusCard extends StatelessWidget { class _LatestNutritionStatusCard extends StatelessWidget {
@ -922,10 +888,13 @@ class _LatestNutritionStatusCard extends StatelessWidget {
} }
class _FoodPreviewCard extends StatelessWidget { class _FoodPreviewCard extends StatelessWidget {
const _FoodPreviewCard({required this.item, required this.assetImage}); const _FoodPreviewCard({
required this.item,
required this.fallbackAssetImage,
});
final RekomendasiModel item; final RekomendasiModel item;
final String assetImage; final String fallbackAssetImage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -942,9 +911,9 @@ class _FoodPreviewCard extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Image.asset( _FoodImage(
assetImage, imageUrl: item.thumbnail,
fit: BoxFit.cover, fallbackAssetImage: fallbackAssetImage,
), ),
Positioned( Positioned(
left: 12, left: 12,
@ -1000,8 +969,33 @@ class _FoodPreviewCard extends StatelessWidget {
} }
} }
class _FoodImage extends StatelessWidget {
const _FoodImage({required this.imageUrl, required this.fallbackAssetImage});
final String? imageUrl;
final String fallbackAssetImage;
@override
Widget build(BuildContext context) {
final url = _resolveMediaUrl(imageUrl);
if (url == null) {
return Image.asset(fallbackAssetImage, fit: BoxFit.cover);
}
return Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
Image.asset(fallbackAssetImage, fit: BoxFit.cover),
);
}
}
class _SectionEmptyInfo extends StatelessWidget { class _SectionEmptyInfo extends StatelessWidget {
const _SectionEmptyInfo({required this.message, this.actionLabel, this.onAction}); const _SectionEmptyInfo({
required this.message,
this.actionLabel,
this.onAction,
});
final String message; final String message;
final String? actionLabel; final String? actionLabel;
@ -1016,7 +1010,11 @@ class _SectionEmptyInfo extends StatelessWidget {
Text(message, style: AppTypography.body), Text(message, style: AppTypography.body),
if (actionLabel != null && onAction != null) ...[ if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
PrimaryButton(label: actionLabel!, onPressed: onAction, isOutlined: true), PrimaryButton(
label: actionLabel!,
onPressed: onAction,
isOutlined: true,
),
], ],
], ],
), ),
@ -1082,14 +1080,14 @@ class _NutritionSkeleton extends StatelessWidget {
height: 150, height: 150,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemBuilder: (_, __) => Container( itemBuilder: (context, index) => Container(
width: 260, width: 260,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
), ),
), ),
separatorBuilder: (_, __) => const SizedBox(width: 12), separatorBuilder: (context, index) => const SizedBox(width: 12),
itemCount: 3, itemCount: 3,
), ),
), ),
@ -1113,10 +1111,7 @@ class _NutritionSkeleton extends StatelessWidget {
} }
class _ArticleCard extends StatelessWidget { class _ArticleCard extends StatelessWidget {
const _ArticleCard({ const _ArticleCard({required this.article, required this.allArticles});
required this.article,
required this.allArticles,
});
final NewsArticleModel article; final NewsArticleModel article;
final List<NewsArticleModel> allArticles; final List<NewsArticleModel> allArticles;
@ -1129,10 +1124,7 @@ class _ArticleCard extends StatelessWidget {
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
fadeRoute( fadeRoute(
ArticleDetailScreen( ArticleDetailScreen(article: article, related: allArticles),
article: article,
related: allArticles,
),
), ),
); );
}, },
@ -1142,14 +1134,15 @@ class _ArticleCard extends StatelessWidget {
Hero( Hero(
tag: 'article-${article.id}', tag: 'article-${article.id}',
child: ClipRRect( child: ClipRRect(
borderRadius: borderRadius: const BorderRadius.horizontal(
const BorderRadius.horizontal(left: Radius.circular(20)), left: Radius.circular(20),
),
child: SizedBox( child: SizedBox(
width: 104, width: 104,
height: 96, height: 96,
child: Image.asset( child: _ArticleThumbImage(
_articleAssetByIndex(article.id), imageUrl: article.image,
fit: BoxFit.cover, fallbackAsset: _articleAssetByIndex(article.id),
), ),
), ),
), ),
@ -1218,18 +1211,27 @@ class _ArticleCard extends StatelessWidget {
} }
} }
class _EducationItem { class _ArticleThumbImage extends StatelessWidget {
const _EducationItem({ const _ArticleThumbImage({
required this.title, required this.imageUrl,
required this.description, required this.fallbackAsset,
required this.icon,
required this.categories,
}); });
final String title; final String? imageUrl;
final String description; final String fallbackAsset;
final IconData icon;
final List<String> categories; @override
Widget build(BuildContext context) {
final resolved = _resolveMediaUrl(imageUrl);
if (resolved == null) {
return Image.asset(fallbackAsset, fit: BoxFit.cover);
}
return Image.network(
resolved,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Image.asset(fallbackAsset, fit: BoxFit.cover),
);
}
} }
class _AgeGuide { class _AgeGuide {
@ -1248,37 +1250,6 @@ class _AgeGuide {
final IconData icon; final IconData icon;
} }
const List<_EducationItem> _educationItems = [
_EducationItem(
title: 'Cegah Stunting Sejak Dini',
description:
'Pantau tinggi dan berat badan rutin serta penuhi protein hewani harian agar tumbuh kembang optimal.',
icon: Icons.height_rounded,
categories: ['Stunting', 'Protein', 'Gizi Seimbang'],
),
_EducationItem(
title: 'MPASI Bertahap dan Tepat Tekstur',
description:
'Mulai MPASI usia 6 bulan, tingkatkan tekstur bertahap, dan jaga frekuensi makan sesuai usia.',
icon: Icons.ramen_dining_rounded,
categories: ['MPASI', 'Gizi Seimbang'],
),
_EducationItem(
title: 'Protein untuk Pertumbuhan Tinggi Badan',
description:
'Protein hewani seperti telur, ikan, ayam, dan daging membantu pembentukan jaringan tubuh anak.',
icon: Icons.egg_alt_rounded,
categories: ['Protein', 'Stunting'],
),
_EducationItem(
title: 'Vitamin dan Mineral Esensial',
description:
'Lengkapi menu dengan sayur dan buah berwarna agar kebutuhan vitamin A, C, zat besi, dan zinc terpenuhi.',
icon: Icons.eco_rounded,
categories: ['Vitamin', 'Gizi Seimbang'],
),
];
const List<_AgeGuide> _ageGuides = [ const List<_AgeGuide> _ageGuides = [
_AgeGuide( _AgeGuide(
title: 'Usia 0-6 Bulan', title: 'Usia 0-6 Bulan',
@ -1324,15 +1295,6 @@ String _suggestionForStatus(String status) {
return 'Pertahankan pola makan gizi seimbang dan pemantauan berkala.'; return 'Pertahankan pola makan gizi seimbang dan pemantauan berkala.';
} }
String _imageForIndex(int index) {
const images = [
'https://images.unsplash.com/photo-1512058564366-18510be2db19?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1490645935967-10de6ba17061?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&w=900&q=80',
];
return images[index % images.length];
}
TextStyle _sectionLabelStyle() { TextStyle _sectionLabelStyle() {
return AppTypography.caption.copyWith( return AppTypography.caption.copyWith(
letterSpacing: 1, letterSpacing: 1,
@ -1367,3 +1329,18 @@ String _articleAssetByIndex(int index) {
]; ];
return assets[index % assets.length]; return assets[index % assets.length];
} }
String? _resolveMediaUrl(String? raw) {
final value = raw?.trim() ?? '';
if (value.isEmpty) return null;
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
final baseUri = Uri.tryParse(ApiService().baseUrl);
if (baseUri == null) return value;
final root = baseUri.replace(path: '', query: '', fragment: '').toString();
final normalizedRoot = root.endsWith('/') ? root : '$root/';
final path = value.startsWith('/') ? value.substring(1) : value;
return '$normalizedRoot$path';
}

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../models/api_result_model.dart'; import 'package:s_gizi/models/api_result_model.dart';
import '../models/recommendation_response_model.dart'; import 'package:s_gizi/models/recommendation_response_model.dart';
import '../services/api_service.dart'; import 'package:s_gizi/services/api_service.dart';
import '../utils/nutrition_display_utils.dart'; import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'package:s_gizi/widgets/nutrition_status_badges.dart';
class RecommendationScreen extends StatefulWidget { class RecommendationScreen extends StatefulWidget {
const RecommendationScreen({ const RecommendationScreen({
@ -93,8 +94,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
} }
final data = snapshot.data!; final data = snapshot.data!;
final normalized = normalizeStatus(data.status ?? 'Normal'); final normalized = normalizeStatus(data.status ?? 'Gizi Baik');
final visual = nutritionStatusVisual(data.status ?? 'Normal');
if ((data.status == null || data.status!.isEmpty) && if ((data.status == null || data.status!.isEmpty) &&
data.items.isEmpty) { data.items.isEmpty) {
@ -117,10 +117,23 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
); );
} }
return ListView( return RefreshIndicator(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 28), color: SgColors.primary,
onRefresh: () async {
_retry();
await _future;
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
SgSpacing.pageH,
SgSpacing.pageV,
SgSpacing.pageH,
20,
),
children: [ children: [
HealthCard( HealthCard(
dense: true,
color: const Color(0xFFEAF7F7), color: const Color(0xFFEAF7F7),
borderColor: const Color(0xFFCBEAEA), borderColor: const Color(0xFFCBEAEA),
child: Column( child: Column(
@ -134,32 +147,10 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
letterSpacing: 1.1, letterSpacing: 1.1,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Text( NutritionStatusBadges(status: data.status ?? 'Gizi Baik'),
data.status ?? '-', if (data.measuredAt != null &&
style: AppTypography.h2, data.measuredAt!.isNotEmpty) ...[
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
StatusBadge(
text: normalized.primaryCategory,
color: visual.color,
),
...normalized.categories
.where((category) => category != normalized.primaryCategory)
.map(
(category) => StatusBadge(
text: category,
color: SgColors.primaryDark,
),
),
],
),
if (data.measuredAt != null && data.measuredAt!.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Tanggal Pengukuran: ${formatMeasurementDate(data.measuredAt!)}', 'Tanggal Pengukuran: ${formatMeasurementDate(data.measuredAt!)}',
@ -168,7 +159,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
), ),
), ),
], ],
if (data.childName != null && data.childName!.isNotEmpty) ...[ if (data.childName != null &&
data.childName!.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Nama Anak: ${data.childName!}', 'Nama Anak: ${data.childName!}',
@ -179,17 +171,24 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
), ),
), ),
], ],
const SizedBox(height: 12),
Text(
recommendationStatusExplanation(data.status ?? 'Normal'),
style: AppTypography.body,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(normalized.focusSummary, style: AppTypography.body), Text(
friendlyDashboardSummary(data.status ?? 'Gizi Baik'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.body.copyWith(fontSize: 13),
),
const SizedBox(height: 6),
Text(
normalized.focusSummary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
], ],
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 14),
Text( Text(
'MENAMPILKAN ${data.items.length} MENU', 'MENAMPILKAN ${data.items.length} MENU',
style: AppTypography.caption.copyWith( style: AppTypography.caption.copyWith(
@ -197,14 +196,20 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 12),
...data.items.asMap().entries.map( ...data.items.asMap().entries.map(
(entry) => _FoodCard( (entry) => _FoodCard(
item: entry.value, item: entry.value,
imageUrl: _imageForStatus(data.status ?? 'Normal', entry.key), imageUrl: entry.value.thumbnail?.trim().isNotEmpty == true
? entry.value.thumbnail!.trim()
: _imageForStatus(
data.status ?? 'Gizi Baik',
entry.key,
),
), ),
), ),
], ],
),
); );
}, },
), ),
@ -228,8 +233,7 @@ class _RecommendationViewData {
return _RecommendationViewData( return _RecommendationViewData(
items: response.items, items: response.items,
status: response.resolvedStatus, status: response.resolvedStatus,
childName: childName: (response.measurement?.childName.trim().isNotEmpty ?? false)
(response.measurement?.childName.trim().isNotEmpty ?? false)
? response.measurement?.childName ? response.measurement?.childName
: fallbackChildName, : fallbackChildName,
measuredAt: response.measurement?.tanggalUkur ?? fallbackMeasuredAt, measuredAt: response.measurement?.tanggalUkur ?? fallbackMeasuredAt,
@ -263,22 +267,23 @@ class _FoodCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HealthCard( return HealthCard(
margin: const EdgeInsets.only(bottom: 20), dense: true,
margin: const EdgeInsets.only(bottom: 12),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 8, aspectRatio: 16 / 6.5,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Image.network( Image.network(
imageUrl, imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, _, _) => Container(
color: const Color(0xFFDDEFE8), color: const Color(0xFFDDEFE8),
child: const Icon( child: const Icon(
Icons.restaurant_rounded, Icons.restaurant_rounded,
@ -336,7 +341,7 @@ class _FoodCard extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -3,10 +3,11 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../app_state.dart'; import 'package:s_gizi/app_state.dart';
import '../services/api_service.dart'; import 'package:s_gizi/services/api_service.dart';
import 'edit_profile_screen.dart'; import 'package:s_gizi/utils/nutrition_display_utils.dart';
import 'package:s_gizi/features/profile/screens/edit_profile_screen.dart';
class AccountInfoScreen extends StatefulWidget { class AccountInfoScreen extends StatefulWidget {
const AccountInfoScreen({super.key}); const AccountInfoScreen({super.key});
@ -58,11 +59,15 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
onRetry: _retry, onRetry: _retry,
); );
} }
final profile = _profileCache ?? snapshot.data ?? const <String, dynamic>{}; final profile =
_profileCache ?? snapshot.data ?? const <String, dynamic>{};
final name = (profile['name'] as String? ?? '-').trim(); final name = (profile['name'] as String? ?? '-').trim();
final phone = (profile['phone'] as String? ?? '-').trim(); final phone = (profile['phone'] as String? ?? '-').trim();
final email = (profile['email'] as String? ?? '-').trim(); final email = (profile['email'] as String? ?? '-').trim();
final joinedAt = (profile['joined_at'] as String? ?? '-').trim(); final joinedAtRaw = (profile['joined_at'] as String? ?? '-').trim();
final joinedAt = joinedAtRaw == '-'
? '-'
: formatMeasurementDate(joinedAtRaw);
return SingleChildScrollView( return SingleChildScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
@ -73,63 +78,40 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
HealthCard( HealthCard(
child: Row( child: Row(
children: [ children: [
Stack( SgAvatar(name: name, radius: 39, icon: Icons.person)
clipBehavior: Clip.none, .animate(onPlay: (c) => c.repeat(reverse: true))
children: [ .moveY(begin: 0, end: -2, duration: 1800.ms),
Container(
width: 78,
height: 78,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFEAF7F7),
border: Border.all(color: const Color(0xFF0B7A86), width: 1.8),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: ClipOval(
child: Image.asset(
'assets/image/onboarding_consultation.png',
fit: BoxFit.cover,
),
),
),
),
Positioned(
right: 2,
bottom: 2,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFF34C759),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
begin: 0,
end: -2,
duration: 1800.ms,
),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(name, style: AppTypography.h2, maxLines: 1, overflow: TextOverflow.ellipsis), Text(
name,
style: AppTypography.h2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(phone, style: AppTypography.body, maxLines: 1, overflow: TextOverflow.ellipsis), Text(
phone,
style: AppTypography.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6), const SizedBox(height: 6),
const StatusBadge(text: 'Akun Terverifikasi', color: Color(0xFF0B7A86)), const StatusBadge(
text: 'Akun Terverifikasi',
color: Color(0xFF0B7A86),
),
], ],
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
onPressed: () async { onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>( final updated = await Navigator.of(context)
.push<Map<String, dynamic>>(
fadeRoute( fadeRoute(
EditProfileScreen( EditProfileScreen(
initialName: name, initialName: name,
@ -146,7 +128,10 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
_state.setProfileData(updated); _state.setProfileData(updated);
} }
}, },
icon: const Icon(LucideIcons.pencil, color: Color(0xFF0B7A86)), icon: const Icon(
LucideIcons.pencil,
color: Color(0xFF0B7A86),
),
), ),
], ],
), ),
@ -154,7 +139,9 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Data Akun', 'Data Akun',
style: AppTypography.h2.copyWith(color: SgColors.textPrimary), style: AppTypography.h2.copyWith(
color: SgColors.textPrimary,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
HealthCard( HealthCard(
@ -189,10 +176,11 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
PrimaryButton( PrimaryButton(
label: 'Edit Profile', label: 'Edit Profil',
icon: LucideIcons.arrowRight, icon: LucideIcons.arrowRight,
onPressed: () async { onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>( final updated = await Navigator.of(context)
.push<Map<String, dynamic>>(
fadeRoute( fadeRoute(
EditProfileScreen( EditProfileScreen(
initialName: name, initialName: name,
@ -280,13 +268,24 @@ class _AccountSkeleton extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column( child: Column(
children: [ children: [
Container(height: 120, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))), Container(
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Container(height: 230, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))), Container(
height: 230,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
),
], ],
), ),
), ),
); );
} }
} }

View File

@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
import '../app_state.dart'; import 'package:s_gizi/app_state.dart';
import '../services/api_service.dart'; import 'package:s_gizi/services/api_service.dart';
class EditProfileScreen extends StatefulWidget { class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({ const EditProfileScreen({
@ -37,16 +37,22 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
_nameController = TextEditingController(text: widget.initialName); _nameController = TextEditingController(text: widget.initialName);
_phoneController = TextEditingController(text: widget.initialPhone); _phoneController = TextEditingController(text: widget.initialPhone);
_emailController = TextEditingController(text: widget.initialEmail); _emailController = TextEditingController(text: widget.initialEmail);
_nameController.addListener(_refreshAvatar);
} }
@override @override
void dispose() { void dispose() {
_nameController.removeListener(_refreshAvatar);
_nameController.dispose(); _nameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_emailController.dispose(); _emailController.dispose();
super.dispose(); super.dispose();
} }
void _refreshAvatar() {
if (mounted) setState(() {});
}
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true); setState(() => _saving = true);
@ -60,9 +66,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _saving = false); setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Gagal menyimpan: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Gagal menyimpan: $e')));
return; return;
} }
if (!mounted) return; if (!mounted) return;
@ -78,8 +84,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F7F6), backgroundColor: const Color(0xFFF5F7F6),
resizeToAvoidBottomInset: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Edit Profile'), title: const Text('Edit Profil'),
backgroundColor: const Color(0xFFF5F7F6), backgroundColor: const Color(0xFFF5F7F6),
), ),
body: SafeArea( body: SafeArea(
@ -95,62 +102,20 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
child: Center( child: Center(
child: Column( child: Column(
children: [ children: [
Stack( SgAvatar(
clipBehavior: Clip.none, name: _nameController.text,
children: [ radius: 50,
Container( icon: Icons.person_rounded,
width: 98, )
height: 98, .animate(onPlay: (c) => c.repeat(reverse: true))
decoration: BoxDecoration( .scale(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF0B7A86), width: 2),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: ClipOval(
child: Image.asset(
'assets/image/onboarding_consultation.png',
fit: BoxFit.cover,
),
),
),
),
Positioned(
right: -2,
bottom: -2,
child: InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Upload foto akan tersedia di versi berikutnya.')),
);
},
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFF0B7A86),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
begin: const Offset(0.99, 0.99), begin: const Offset(0.99, 0.99),
end: const Offset(1.02, 1.02), end: const Offset(1.02, 1.02),
duration: 1700.ms, duration: 1700.ms,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'Ubah Foto Profile', getInitialName(_nameController.text),
style: AppTypography.caption.copyWith( style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86), color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -165,14 +130,18 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
controller: _nameController, controller: _nameController,
label: 'Nama Lengkap', label: 'Nama Lengkap',
icon: LucideIcons.user, icon: LucideIcons.user,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Nama wajib diisi.' : null, validator: (v) => (v == null || v.trim().isEmpty)
? 'Nama wajib diisi.'
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_InputField( _InputField(
controller: _phoneController, controller: _phoneController,
label: 'Nomor Telepon', label: 'Nomor Telepon',
icon: LucideIcons.messageCircle, icon: LucideIcons.messageCircle,
validator: (v) => (v == null || v.trim().length < 8) ? 'Nomor telepon tidak valid.' : null, validator: (v) => (v == null || v.trim().length < 8)
? 'Nomor telepon tidak valid.'
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_InputField( _InputField(
@ -182,7 +151,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
validator: (v) { validator: (v) {
final value = (v ?? '').trim(); final value = (v ?? '').trim();
if (value.isEmpty) return null; if (value.isEmpty) return null;
return value.contains('@') ? null : 'Format email tidak valid.'; return value.contains('@')
? null
: 'Format email tidak valid.';
}, },
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@ -207,23 +178,34 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
), ),
child: _saving child: _saving
? const SizedBox( ? const SizedBox(
width: 22, width: 22,
height: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
) )
: Row( : Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
'Simpan Perubahan', 'Simpan Perubahan',
style: AppTypography.h3.copyWith(color: Colors.white), style: AppTypography.h3.copyWith(
color: Colors.white,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon(LucideIcons.arrowRight, color: Colors.white, size: 18), const Icon(
LucideIcons.arrowRight,
color: Colors.white,
size: 18,
),
], ],
), ),
), ),
@ -276,4 +258,3 @@ class _InputField extends StatelessWidget {
); );
} }
} }

View File

@ -3,7 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../app_design.dart'; import 'package:s_gizi/app_design.dart';
class HelpScreen extends StatefulWidget { class HelpScreen extends StatefulWidget {
const HelpScreen({super.key}); const HelpScreen({super.key});
@ -62,14 +62,16 @@ class _HelpScreenState extends State<HelpScreen> {
icon: LucideIcons.messageCircle, icon: LucideIcons.messageCircle,
title: 'WhatsApp Admin', title: 'WhatsApp Admin',
subtitle: '081249583765', subtitle: '081249583765',
onTap: () => _launch(context, 'https://wa.me/6281249583765'), onTap: () =>
_launch(context, 'https://wa.me/6281249583765'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_ContactButton( _ContactButton(
icon: LucideIcons.info, icon: LucideIcons.info,
title: 'Email', title: 'Email',
subtitle: 'smartgiziapp@gmail.com', subtitle: 'smartgiziapp@gmail.com',
onTap: () => _launch(context, 'mailto:smartgiziapp@gmail.com'), onTap: () =>
_launch(context, 'mailto:smartgiziapp@gmail.com'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_ContactButton( _ContactButton(
@ -133,7 +135,9 @@ class _FaqCardState extends State<_FaqCard> {
padding: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10),
child: Text(widget.answer, style: AppTypography.body), child: Text(widget.answer, style: AppTypography.body),
), ),
crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, crossFadeState: _expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
), ),
], ],
@ -210,4 +214,3 @@ Future<void> _launch(BuildContext context, String url) async {
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,451 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
import 'package:s_gizi/features/auth/screens/auth_screen.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({super.key});
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _api = ApiService();
final _oldPassword = TextEditingController();
final _newPassword = TextEditingController();
final _confirmPassword = TextEditingController();
bool _hideOld = true;
bool _hideNew = true;
bool _hideConfirm = true;
bool _savingPassword = false;
bool _loggingOutAll = false;
bool _deletingAccount = false;
@override
void dispose() {
_oldPassword.dispose();
_newPassword.dispose();
_confirmPassword.dispose();
super.dispose();
}
Future<void> _savePassword() async {
if (_oldPassword.text.isEmpty) {
_snack('Password lama wajib diisi.');
return;
}
if (_newPassword.text.length < 8) {
_snack('Password baru minimal 8 karakter.');
return;
}
if (_newPassword.text != _confirmPassword.text) {
_snack('Konfirmasi password baru belum cocok.');
return;
}
setState(() => _savingPassword = true);
try {
await _api.updatePassword(
oldPassword: _oldPassword.text,
newPassword: _newPassword.text,
newPasswordConfirmation: _confirmPassword.text,
);
if (!mounted) return;
_oldPassword.clear();
_newPassword.clear();
_confirmPassword.clear();
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
title: const Text('Password Berhasil Diubah'),
content: const Text(
'Akun Anda tetap aman. Gunakan password baru saat login berikutnya.',
),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Mengerti'),
),
],
),
);
} catch (e) {
if (!mounted) return;
_snack(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _savingPassword = false);
}
}
void _snack(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<bool> _confirmDanger(String title, String message) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) =>
AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
icon: const Icon(
Icons.warning_amber_rounded,
color: SgColors.danger,
),
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: SgColors.danger,
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Lanjutkan'),
),
],
)
.animate()
.fadeIn(duration: 180.ms)
.scale(
begin: const Offset(0.96, 0.96),
end: const Offset(1, 1),
duration: 180.ms,
),
);
return confirmed == true;
}
Future<void> _logoutAllDevices() async {
final ok = await _confirmDanger(
'Logout Semua Perangkat?',
'Anda perlu login ulang di semua perangkat setelah tindakan ini.',
);
if (!ok || _loggingOutAll) return;
setState(() => _loggingOutAll = true);
try {
await _api.logoutAllDevices();
await SgiziAppState.instance.logout();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Berhasil logout dari semua perangkat.')),
);
Navigator.of(
context,
).pushAndRemoveUntil(fadeRoute(const AuthScreen()), (_) => false);
} catch (e) {
if (!mounted) return;
_snack(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _loggingOutAll = false);
}
}
Future<void> _deleteAccount() async {
final ok = await _confirmDanger(
'Hapus Akun?',
'Tindakan ini bersifat permanen dan akan menghapus akun beserta data anak yang terhubung.',
);
if (!ok || _deletingAccount) return;
setState(() => _deletingAccount = true);
try {
await _api.deleteAccount();
await SgiziAppState.instance.logout();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Akun berhasil dihapus.')));
Navigator.of(
context,
).pushAndRemoveUntil(fadeRoute(const AuthScreen()), (_) => false);
} catch (e) {
if (!mounted) return;
_snack(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _deletingAccount = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
title: const Text('Privasi & Keamanan'),
backgroundColor: const Color(0xFFF5F7F6),
),
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SecurityActionCard(
icon: LucideIcons.lock,
title: 'Ubah Password',
subtitle: 'Perbarui password akun orang tua',
child: Column(
children: [
_PasswordField(
controller: _oldPassword,
label: 'Password Lama',
hidden: _hideOld,
onToggle: () => setState(() => _hideOld = !_hideOld),
),
const SizedBox(height: 10),
_PasswordField(
controller: _newPassword,
label: 'Password Baru',
hidden: _hideNew,
onToggle: () => setState(() => _hideNew = !_hideNew),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Minimal 8 karakter. Data password disimpan aman oleh server.',
style: AppTypography.caption,
),
),
const SizedBox(height: 10),
_PasswordField(
controller: _confirmPassword,
label: 'Konfirmasi Password Baru',
hidden: _hideConfirm,
onToggle: () =>
setState(() => _hideConfirm = !_hideConfirm),
),
const SizedBox(height: 14),
PrimaryButton(
label: _savingPassword
? 'Menyimpan...'
: 'Simpan Password',
icon: LucideIcons.check,
onPressed: _savingPassword ? null : _savePassword,
),
],
),
),
const SizedBox(height: 14),
_SimpleSecurityTile(
icon: LucideIcons.logOut,
title: 'Logout Semua Perangkat',
subtitle: _loggingOutAll
? 'Memproses logout semua sesi...'
: 'Keluar dari semua sesi aktif',
loading: _loggingOutAll,
onTap: _loggingOutAll ? null : _logoutAllDevices,
),
const SizedBox(height: 10),
_SimpleSecurityTile(
icon: LucideIcons.trash2,
danger: true,
title: 'Hapus Akun',
subtitle: _deletingAccount
? 'Menghapus akun...'
: 'Hapus akun dan data yang terhubung',
loading: _deletingAccount,
onTap: _deletingAccount ? null : _deleteAccount,
),
const SizedBox(height: 10),
const _PrivacyPolicyCard(),
],
),
),
),
).animate().fadeIn(duration: 220.ms).slideY(begin: 0.02, end: 0);
}
}
class _SecurityActionCard extends StatelessWidget {
const _SecurityActionCard({
required this.icon,
required this.title,
required this.subtitle,
required this.child,
});
final IconData icon;
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_SecurityIcon(icon: icon),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.h2),
Text(subtitle, style: AppTypography.caption),
],
),
),
],
),
const SizedBox(height: 16),
child,
],
),
);
}
}
class _SimpleSecurityTile extends StatelessWidget {
const _SimpleSecurityTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.danger = false,
this.loading = false,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback? onTap;
final bool danger;
final bool loading;
@override
Widget build(BuildContext context) {
final color = danger ? SgColors.danger : SgColors.primary;
return HealthCard(
dense: true,
onTap: onTap,
child: Row(
children: [
_SecurityIcon(icon: icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.h3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
],
),
),
if (loading)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: color),
)
else
const Icon(LucideIcons.chevronRight, color: SgColors.textSecondary),
],
),
);
}
}
class _PrivacyPolicyCard extends StatelessWidget {
const _PrivacyPolicyCard();
@override
Widget build(BuildContext context) {
return const HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_SecurityIcon(icon: LucideIcons.shieldCheck),
SizedBox(width: 12),
Expanded(
child: Text('Kebijakan Privasi', style: AppTypography.h3),
),
],
),
SizedBox(height: 10),
Text(
'Data pengguna S-Gizi digunakan untuk layanan monitoring, rekomendasi, dan edukasi gizi. Password dikelola oleh server secara aman dan tidak disimpan sebagai teks biasa di aplikasi.',
style: AppTypography.body,
),
],
),
);
}
}
class _SecurityIcon extends StatelessWidget {
const _SecurityIcon({required this.icon, this.color = SgColors.primary});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: 22,
backgroundColor: color.withValues(alpha: 0.12),
child: Icon(icon, color: color, size: 20),
);
}
}
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.label,
required this.hidden,
required this.onToggle,
this.onChanged,
});
final TextEditingController controller;
final String label;
final bool hidden;
final VoidCallback onToggle;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
obscureText: hidden,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: const Icon(LucideIcons.shield, color: SgColors.primary),
suffixIcon: IconButton(
onPressed: onToggle,
icon: Icon(hidden ? LucideIcons.eye : LucideIcons.eyeOff),
),
),
);
}
}

View File

@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_design.dart'; import 'package:s_gizi/app_design.dart';
import 'services/local_notification_service.dart'; import 'package:s_gizi/services/local_notification_service.dart';
import 'screens/splash_screen.dart'; import 'package:s_gizi/features/auth/screens/splash_screen.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await LocalNotificationService.instance.init(); await LocalNotificationService.instance.init();
runApp(const GiziApp()); runApp(const GiziApp());
} }

View File

@ -47,6 +47,12 @@ class AnalysisMeasurementModel {
required this.childName, required this.childName,
required this.tanggalUkur, required this.tanggalUkur,
required this.caraUkur, required this.caraUkur,
this.isAnomaly = false,
this.dataStatus = 'normal',
this.validationStatus = 'valid',
this.validationNote = '',
this.monitoringStatus = 'normal',
this.isConfirmedByParent = false,
}); });
final int id; final int id;
@ -54,6 +60,12 @@ class AnalysisMeasurementModel {
final String childName; final String childName;
final String tanggalUkur; final String tanggalUkur;
final String? caraUkur; final String? caraUkur;
final bool isAnomaly;
final String dataStatus;
final String validationStatus;
final String validationNote;
final String monitoringStatus;
final bool isConfirmedByParent;
factory AnalysisMeasurementModel.fromJson(Map<String, dynamic> json) { factory AnalysisMeasurementModel.fromJson(Map<String, dynamic> json) {
return AnalysisMeasurementModel( return AnalysisMeasurementModel(
@ -62,15 +74,30 @@ class AnalysisMeasurementModel {
childName: json['child_name'] as String? ?? '-', childName: json['child_name'] as String? ?? '-',
tanggalUkur: json['tanggal_ukur'] as String? ?? '-', tanggalUkur: json['tanggal_ukur'] as String? ?? '-',
caraUkur: json['cara_ukur'] as String?, caraUkur: json['cara_ukur'] as String?,
isAnomaly: json['is_anomaly'] == true,
dataStatus: json['data_status'] as String? ?? 'normal',
validationStatus: json['validation_status'] as String? ?? 'valid',
validationNote: json['validation_note'] as String? ?? '',
monitoringStatus: json['monitoring_status'] as String? ?? 'normal',
isConfirmedByParent: json['is_confirmed_by_parent'] == true,
); );
} }
} }
class IdentitasModel { class IdentitasModel {
const IdentitasModel({required this.umurBulan, required this.jenisKelamin}); const IdentitasModel({
required this.umurBulan,
required this.jenisKelamin,
required this.caraUkur,
required this.standarBbtb,
this.umurHari,
});
final double umurBulan; final double umurBulan;
final String jenisKelamin; final String jenisKelamin;
final String caraUkur;
final String standarBbtb;
final int? umurHari;
factory IdentitasModel.fromJson(Map<String, dynamic> json) { factory IdentitasModel.fromJson(Map<String, dynamic> json) {
double parseNum(dynamic v) { double parseNum(dynamic v) {
@ -81,6 +108,9 @@ class IdentitasModel {
return IdentitasModel( return IdentitasModel(
umurBulan: parseNum(json['umur_bulan']), umurBulan: parseNum(json['umur_bulan']),
jenisKelamin: json['jenis_kelamin'] as String? ?? '-', jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
caraUkur: json['cara_ukur'] as String? ?? '-',
standarBbtb: json['standar_bbtb'] as String? ?? 'BB/TB',
umurHari: (json['umur_hari'] as num?)?.toInt(),
); );
} }
} }
@ -134,6 +164,7 @@ class RekomendasiModel {
required this.lemak, required this.lemak,
required this.karbohidrat, required this.karbohidrat,
required this.alasan, required this.alasan,
this.thumbnail,
}); });
final String menu; final String menu;
@ -142,6 +173,7 @@ class RekomendasiModel {
final int lemak; final int lemak;
final int karbohidrat; final int karbohidrat;
final String alasan; final String alasan;
final String? thumbnail;
factory RekomendasiModel.fromJson(Map<String, dynamic> json) { factory RekomendasiModel.fromJson(Map<String, dynamic> json) {
int parseInt(dynamic v) { int parseInt(dynamic v) {
@ -157,6 +189,10 @@ class RekomendasiModel {
lemak: parseInt(json['lemak']), lemak: parseInt(json['lemak']),
karbohidrat: parseInt(json['karbohidrat']), karbohidrat: parseInt(json['karbohidrat']),
alasan: json['alasan'] as String? ?? '-', alasan: json['alasan'] as String? ?? '-',
thumbnail:
json['thumbnail'] as String? ??
json['image'] as String? ??
json['image_url'] as String?,
); );
} }
} }

View File

@ -0,0 +1,108 @@
class ArticleModel {
const ArticleModel({
required this.id,
required this.title,
required this.description,
required this.content,
required this.category,
required this.publishedAt,
this.source,
this.author,
this.imageUrl,
this.articleUrl,
});
final int id;
final String title;
final String description;
final String content;
final String category;
final String publishedAt;
final String? source;
final String? author;
final String? imageUrl;
final String? articleUrl;
factory ArticleModel.fromJson(Map<String, dynamic> json) {
final source = json['source'];
final sourceName = source is Map<String, dynamic>
? source['name'] as String?
: json['source_name'] as String?;
return ArticleModel(
id: _stableId(
json['id']?.toString() ??
json['url'] as String? ??
json['articleUrl'] as String? ??
json['title'] as String? ??
'',
),
title: (json['title'] as String? ?? '-').trim(),
description:
(json['description'] as String? ?? json['excerpt'] as String? ?? '-')
.trim(),
content: (json['content'] as String? ?? '').trim(),
category: _categoryFromJson(json),
publishedAt:
(json['publishedAt'] as String? ??
json['published_at'] as String? ??
json['created_at'] as String? ??
'')
.trim(),
source: sourceName?.trim().isEmpty == true ? null : sourceName?.trim(),
author: (json['author'] as String?)?.trim(),
imageUrl:
(json['image'] as String? ??
json['image_url'] as String? ??
json['urlToImage'] as String?)
?.trim(),
articleUrl: (json['url'] as String? ?? json['articleUrl'] as String?)
?.trim(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'content': content,
'category': category,
'published_at': publishedAt,
'source_name': source,
'author': author,
'image_url': imageUrl,
'url': articleUrl,
};
}
}
String _categoryFromJson(Map<String, dynamic> json) {
final raw = (json['category'] as String? ?? '').trim();
if (raw.isNotEmpty) return raw;
final text = '${json['title'] ?? ''} ${json['description'] ?? ''}'
.toLowerCase();
if (text.contains('stunting')) return 'Stunting';
if (text.contains('mpasi')) return 'MPASI';
if (text.contains('imunisasi') || text.contains('vaksin')) {
return 'Imunisasi';
}
if (text.contains('kesehatan anak') || text.contains('balita')) {
return 'Kesehatan Anak';
}
if (text.contains('protein')) return 'Protein';
if (text.contains('vitamin')) return 'Vitamin';
if (text.contains('tumbuh') || text.contains('kembang')) {
return 'Tumbuh Kembang';
}
return 'Gizi';
}
int _stableId(String value) {
var hash = 0;
for (final codeUnit in value.codeUnits) {
hash = (hash * 31 + codeUnit) & 0x7fffffff;
}
return hash;
}

View File

@ -0,0 +1,60 @@
class ChatMessageModel {
const ChatMessageModel({
required this.id,
required this.consultationId,
required this.senderId,
required this.senderRole,
required this.message,
required this.isRead,
required this.createdAt,
});
final int id;
final int consultationId;
final int senderId;
final String senderRole;
final String message;
final bool isRead;
final String createdAt;
bool get fromNutritionist {
final role = senderRole.toLowerCase();
return role == 'nutritionist' || role == 'expert' || role == 'ahli_gizi';
}
factory ChatMessageModel.fromJson(Map<String, dynamic> json) {
return ChatMessageModel(
id: _int(json['id']),
consultationId: _int(json['consultation_id'] ?? json['room_id']),
senderId: _int(json['sender_id']),
senderRole: _string(
json['sender_role'] ?? json['sender_type'],
fallback: 'parent',
),
message: _string(json['message']),
isRead: json['is_read'] == true,
createdAt: _string(json['created_at'], fallback: '-'),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'consultation_id': consultationId,
'sender_id': senderId,
'sender_role': senderRole,
'message': message,
'is_read': isRead,
'created_at': createdAt,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,89 @@
import 'child_detail_model.dart';
import 'measurement_history_model.dart';
import 'nutritionist_note_model.dart';
import 'zscore_result_model.dart';
class ChildChatDetailModel {
const ChildChatDetailModel({
required this.id,
required this.name,
required this.ageText,
required this.gender,
required this.parentName,
required this.parentPhone,
required this.riskStatus,
required this.latestMeasurement,
required this.zscoreResult,
required this.interpretation,
required this.shortHistories,
required this.notes,
});
final int id;
final String name;
final String ageText;
final String gender;
final String parentName;
final String parentPhone;
final String riskStatus;
final LatestMeasurementModel latestMeasurement;
final ZScoreResultModel zscoreResult;
final String interpretation;
final List<MeasurementHistoryModel> shortHistories;
final List<NutritionistNoteModel> notes;
factory ChildChatDetailModel.fromJson(Map<String, dynamic> json) {
final latest = json['latest_measurement'] is Map<String, dynamic>
? json['latest_measurement'] as Map<String, dynamic>
: const <String, dynamic>{};
final zscore = json['zscore_result'] is Map<String, dynamic>
? json['zscore_result'] as Map<String, dynamic>
: const <String, dynamic>{};
return ChildChatDetailModel(
id: _int(json['id']),
name: _string(json['name'], fallback: 'Anak'),
ageText: _string(json['age_text'], fallback: '-'),
gender: _string(json['gender'], fallback: '-'),
parentName: _string(json['parent_name'], fallback: '-'),
parentPhone: _string(json['parent_phone'], fallback: '-'),
riskStatus: _string(json['risk_status'], fallback: 'Normal'),
latestMeasurement: LatestMeasurementModel.fromJson(latest),
zscoreResult: ZScoreResultModel.fromJson(zscore),
interpretation: _string(json['interpretation'], fallback: '-'),
shortHistories: (json['short_histories'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(MeasurementHistoryModel.fromJson)
.toList(),
notes: (json['notes'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(NutritionistNoteModel.fromJson)
.toList(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'age_text': ageText,
'gender': gender,
'parent_name': parentName,
'parent_phone': parentPhone,
'risk_status': riskStatus,
'latest_measurement': latestMeasurement.toJson(),
'zscore_result': zscoreResult.toJson(),
'interpretation': interpretation,
'short_histories': shortHistories.map((e) => e.toJson()).toList(),
'notes': notes.map((e) => e.toJson()).toList(),
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,138 @@
import 'measurement_history_model.dart';
import 'zscore_result_model.dart';
class LatestMeasurementModel {
const LatestMeasurementModel({
required this.measurementId,
required this.measurementDate,
required this.ageAtMeasurement,
required this.weightKg,
required this.heightCm,
required this.position,
});
final int measurementId;
final String measurementDate;
final String ageAtMeasurement;
final double weightKg;
final double heightCm;
final String position;
factory LatestMeasurementModel.fromJson(Map<String, dynamic> json) {
return LatestMeasurementModel(
measurementId: _int(json['measurement_id']),
measurementDate: _string(json['measurement_date'], fallback: '-'),
ageAtMeasurement: _string(json['age_at_measurement'], fallback: '-'),
weightKg: _double(json['weight_kg']),
heightCm: _double(json['height_cm']),
position: _string(json['position'], fallback: '-'),
);
}
Map<String, dynamic> toJson() => {
'measurement_id': measurementId,
'measurement_date': measurementDate,
'age_at_measurement': ageAtMeasurement,
'weight_kg': weightKg,
'height_cm': heightCm,
'position': position,
};
}
class ChildDetailModel {
const ChildDetailModel({
required this.id,
required this.name,
required this.ageText,
required this.gender,
required this.birthDate,
required this.parentName,
required this.parentPhone,
required this.riskStatus,
required this.latestMeasurement,
required this.zscoreResult,
required this.interpretation,
required this.shortHistories,
required this.hasConsultation,
this.consultationId,
});
final int id;
final String name;
final String ageText;
final String gender;
final String birthDate;
final String parentName;
final String parentPhone;
final String riskStatus;
final LatestMeasurementModel latestMeasurement;
final ZScoreResultModel zscoreResult;
final String interpretation;
final List<MeasurementHistoryModel> shortHistories;
final bool hasConsultation;
final int? consultationId;
factory ChildDetailModel.fromJson(Map<String, dynamic> json) {
final latest = json['latest_measurement'] is Map<String, dynamic>
? json['latest_measurement'] as Map<String, dynamic>
: const <String, dynamic>{};
final zscore = json['zscore_result'] is Map<String, dynamic>
? json['zscore_result'] as Map<String, dynamic>
: const <String, dynamic>{};
return ChildDetailModel(
id: _int(json['id']),
name: _string(json['name'], fallback: 'Anak'),
ageText: _string(json['age_text'], fallback: '-'),
gender: _string(json['gender'], fallback: '-'),
birthDate: _string(json['birth_date'], fallback: '-'),
parentName: _string(json['parent_name'], fallback: '-'),
parentPhone: _string(json['parent_phone'], fallback: '-'),
riskStatus: _string(json['risk_status'], fallback: 'Normal'),
latestMeasurement: LatestMeasurementModel.fromJson(latest),
zscoreResult: ZScoreResultModel.fromJson(zscore),
interpretation: _string(json['interpretation'], fallback: '-'),
shortHistories: (json['short_histories'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(MeasurementHistoryModel.fromJson)
.toList(),
hasConsultation: json['has_consultation'] == true,
consultationId: json['consultation_id'] is num
? (json['consultation_id'] as num).toInt()
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'age_text': ageText,
'gender': gender,
'birth_date': birthDate,
'parent_name': parentName,
'parent_phone': parentPhone,
'risk_status': riskStatus,
'latest_measurement': latestMeasurement.toJson(),
'zscore_result': zscoreResult.toJson(),
'interpretation': interpretation,
'short_histories': shortHistories.map((item) => item.toJson()).toList(),
'has_consultation': hasConsultation,
'consultation_id': consultationId,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
double _double(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,140 @@
class ChildMonitoringSummary {
const ChildMonitoringSummary({
required this.total,
required this.highRisk,
required this.anomaly,
required this.normal,
});
final int total;
final int highRisk;
final int anomaly;
final int normal;
factory ChildMonitoringSummary.fromJson(Map<String, dynamic> json) {
return ChildMonitoringSummary(
total: _int(json['total']),
highRisk: _int(json['high_risk']),
anomaly: _int(json['anomaly']),
normal: _int(json['normal']),
);
}
Map<String, dynamic> toJson() => {
'total': total,
'high_risk': highRisk,
'anomaly': anomaly,
'normal': normal,
};
static const empty = ChildMonitoringSummary(
total: 0,
highRisk: 0,
anomaly: 0,
normal: 0,
);
}
class ChildMonitoringModel {
const ChildMonitoringModel({
required this.id,
required this.name,
required this.ageText,
required this.gender,
required this.parentName,
required this.parentPhone,
required this.riskStatus,
required this.bbuStatus,
required this.tbuStatus,
required this.bbtbStatus,
required this.weightKg,
required this.heightCm,
required this.zscoreTbu,
required this.lastMeasurementDate,
required this.isAnomaly,
required this.hasConsultation,
this.consultationId,
});
final int id;
final String name;
final String ageText;
final String gender;
final String parentName;
final String parentPhone;
final String riskStatus;
final String bbuStatus;
final String tbuStatus;
final String bbtbStatus;
final double weightKg;
final double heightCm;
final double zscoreTbu;
final String lastMeasurementDate;
final bool isAnomaly;
final bool hasConsultation;
final int? consultationId;
factory ChildMonitoringModel.fromJson(Map<String, dynamic> json) {
return ChildMonitoringModel(
id: _int(json['id']),
name: _string(json['name'], fallback: 'Anak'),
ageText: _string(json['age_text'], fallback: '-'),
gender: _string(json['gender'], fallback: '-'),
parentName: _string(json['parent_name'], fallback: '-'),
parentPhone: _string(json['parent_phone'], fallback: '-'),
riskStatus: _string(json['risk_status'], fallback: 'Normal'),
bbuStatus: _string(json['bbu_status'], fallback: '-'),
tbuStatus: _string(json['tbu_status'], fallback: '-'),
bbtbStatus: _string(json['bbtb_status'], fallback: '-'),
weightKg: _double(json['weight_kg']),
heightCm: _double(json['height_cm']),
zscoreTbu: _double(json['zscore_tbu']),
lastMeasurementDate: _string(
json['last_measurement_date'],
fallback: '-',
),
isAnomaly: json['is_anomaly'] == true,
hasConsultation: json['has_consultation'] == true,
consultationId: json['consultation_id'] is num
? (json['consultation_id'] as num).toInt()
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'age_text': ageText,
'gender': gender,
'parent_name': parentName,
'parent_phone': parentPhone,
'risk_status': riskStatus,
'bbu_status': bbuStatus,
'tbu_status': tbuStatus,
'bbtb_status': bbtbStatus,
'weight_kg': weightKg,
'height_cm': heightCm,
'zscore_tbu': zscoreTbu,
'last_measurement_date': lastMeasurementDate,
'is_anomaly': isAnomaly,
'has_consultation': hasConsultation,
'consultation_id': consultationId,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
double _double(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,85 @@
class ConsultationModel {
const ConsultationModel({
required this.id,
required this.parentName,
required this.childName,
required this.childAge,
required this.riskStatus,
required this.lastMessage,
required this.lastMessageTime,
required this.unreadCount,
required this.status,
});
final int id;
final String parentName;
final String childName;
final String childAge;
final String riskStatus;
final String lastMessage;
final String lastMessageTime;
final int unreadCount;
final String status;
bool get isClosed {
final value = status.toLowerCase();
return value == 'closed' || value == 'selesai' || value == 'resolved';
}
factory ConsultationModel.fromJson(Map<String, dynamic> json) {
return ConsultationModel(
id: _int(json['id']),
parentName: _string(json['parent_name'], fallback: 'Orang Tua'),
childName: _string(json['child_name'], fallback: 'Anak'),
childAge: _string(json['child_age'], fallback: '-'),
riskStatus: _riskLabel(json['risk_status'] ?? json['risk']),
lastMessage: _string(json['last_message'], fallback: 'Belum ada pesan.'),
lastMessageTime: _string(
json['last_message_time'] ??
json['last_message_at'] ??
json['updated_at'],
fallback: '-',
),
unreadCount: _int(json['unread_count']),
status: _string(
json['status'] ?? json['room_status'],
fallback: 'active',
),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'parent_name': parentName,
'child_name': childName,
'child_age': childAge,
'risk_status': riskStatus,
'last_message': lastMessage,
'last_message_time': lastMessageTime,
'unread_count': unreadCount,
'status': status,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
String _riskLabel(dynamic value) {
final raw = _string(value, fallback: 'Normal');
switch (raw.toLowerCase()) {
case 'high':
return 'Risiko Tinggi';
case 'warning':
return 'Perlu Dipantau';
default:
return raw;
}
}

View File

@ -0,0 +1,27 @@
class ConsultationStatusModel {
const ConsultationStatusModel({
required this.hasConsultation,
this.consultationId,
this.status = '',
});
final bool hasConsultation;
final int? consultationId;
final String status;
factory ConsultationStatusModel.fromJson(Map<String, dynamic> json) {
return ConsultationStatusModel(
hasConsultation: json['has_consultation'] == true,
consultationId: json['consultation_id'] is num
? (json['consultation_id'] as num).toInt()
: null,
status: json['status'] as String? ?? '',
);
}
Map<String, dynamic> toJson() => {
'has_consultation': hasConsultation,
'consultation_id': consultationId,
'status': status,
};
}

View File

@ -0,0 +1,97 @@
import 'consultation_model.dart';
import 'notification_model.dart';
import 'nutritionist_profile_model.dart';
class DashboardSummaryModel {
const DashboardSummaryModel({
required this.activeConsultations,
required this.unrepliedMessages,
required this.highRiskConsultations,
required this.needReviewData,
});
final int activeConsultations;
final int unrepliedMessages;
final int highRiskConsultations;
final int needReviewData;
factory DashboardSummaryModel.fromJson(Map<String, dynamic> json) {
return DashboardSummaryModel(
activeConsultations: _int(json['active_consultations']),
unrepliedMessages: _int(json['unreplied_messages'] ?? json['unanswered']),
highRiskConsultations: _int(
json['high_risk_consultations'] ?? json['risk_children'],
),
needReviewData: _int(json['need_review_data'] ?? json['anomaly_data']),
);
}
Map<String, dynamic> toJson() => {
'active_consultations': activeConsultations,
'unreplied_messages': unrepliedMessages,
'high_risk_consultations': highRiskConsultations,
'need_review_data': needReviewData,
};
}
class DashboardNutritionistModel {
const DashboardNutritionistModel({
required this.nutritionist,
required this.summary,
required this.latestConsultations,
required this.latestNotifications,
});
final NutritionistProfileModel nutritionist;
final DashboardSummaryModel summary;
final List<ConsultationModel> latestConsultations;
final List<NotificationModel> latestNotifications;
factory DashboardNutritionistModel.fromJson(Map<String, dynamic> json) {
final data = json['data'] is Map<String, dynamic>
? json['data'] as Map<String, dynamic>
: json;
return DashboardNutritionistModel(
nutritionist: NutritionistProfileModel.fromJson(
data['nutritionist'] is Map<String, dynamic>
? data['nutritionist'] as Map<String, dynamic>
: data['profile'] is Map<String, dynamic>
? data['profile'] as Map<String, dynamic>
: const {},
),
summary: DashboardSummaryModel.fromJson(
data['summary'] is Map<String, dynamic>
? data['summary'] as Map<String, dynamic>
: data['stats'] is Map<String, dynamic>
? data['stats'] as Map<String, dynamic>
: const {},
),
latestConsultations:
((data['latest_consultations'] ?? data['rooms']) as List<dynamic>? ??
const [])
.whereType<Map<String, dynamic>>()
.map(ConsultationModel.fromJson)
.toList(),
latestNotifications:
((data['latest_notifications'] ?? data['activities'])
as List<dynamic>? ??
const [])
.whereType<Map<String, dynamic>>()
.map(NotificationModel.fromJson)
.toList(),
);
}
Map<String, dynamic> toJson() => {
'nutritionist': nutritionist.toJson(),
'summary': summary.toJson(),
'latest_consultations': latestConsultations.map((e) => e.toJson()).toList(),
'latest_notifications': latestNotifications.map((e) => e.toJson()).toList(),
};
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,40 @@
class MeasurementHistoryModel {
const MeasurementHistoryModel({
required this.date,
required this.weightKg,
required this.heightCm,
required this.riskStatus,
});
final String date;
final double weightKg;
final double heightCm;
final String riskStatus;
factory MeasurementHistoryModel.fromJson(Map<String, dynamic> json) {
return MeasurementHistoryModel(
date: _string(json['date']),
weightKg: _double(json['weight_kg']),
heightCm: _double(json['height_cm']),
riskStatus: _string(json['risk_status'], fallback: 'Normal'),
);
}
Map<String, dynamic> toJson() => {
'date': date,
'weight_kg': weightKg,
'height_cm': heightCm,
'risk_status': riskStatus,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
double _double(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0;
return 0;
}

View File

@ -4,7 +4,6 @@ class MobileChildModel {
required this.nama, required this.nama,
required this.tanggalLahir, required this.tanggalLahir,
required this.jenisKelamin, required this.jenisKelamin,
this.photoUrl,
this.latestStatus, this.latestStatus,
this.latestMeasurementAt, this.latestMeasurementAt,
}); });
@ -13,7 +12,6 @@ class MobileChildModel {
final String nama; final String nama;
final String tanggalLahir; final String tanggalLahir;
final String jenisKelamin; final String jenisKelamin;
final String? photoUrl;
final String? latestStatus; final String? latestStatus;
final String? latestMeasurementAt; final String? latestMeasurementAt;
@ -23,10 +21,6 @@ class MobileChildModel {
nama: json['nama'] as String? ?? '-', nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] as String? ?? '-', tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
jenisKelamin: json['jenis_kelamin'] as String? ?? '-', jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
photoUrl:
json['photo_url'] as String? ??
json['foto_url'] as String? ??
json['avatar_url'] as String?,
latestStatus: json['latest_status'] as String?, latestStatus: json['latest_status'] as String?,
latestMeasurementAt: json['latest_measurement_at'] as String?, latestMeasurementAt: json['latest_measurement_at'] as String?,
); );
@ -37,7 +31,6 @@ class MobileChildModel {
String? nama, String? nama,
String? tanggalLahir, String? tanggalLahir,
String? jenisKelamin, String? jenisKelamin,
String? photoUrl,
String? latestStatus, String? latestStatus,
String? latestMeasurementAt, String? latestMeasurementAt,
}) { }) {
@ -46,7 +39,6 @@ class MobileChildModel {
nama: nama ?? this.nama, nama: nama ?? this.nama,
tanggalLahir: tanggalLahir ?? this.tanggalLahir, tanggalLahir: tanggalLahir ?? this.tanggalLahir,
jenisKelamin: jenisKelamin ?? this.jenisKelamin, jenisKelamin: jenisKelamin ?? this.jenisKelamin,
photoUrl: photoUrl ?? this.photoUrl,
latestStatus: latestStatus ?? this.latestStatus, latestStatus: latestStatus ?? this.latestStatus,
latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt, latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt,
); );

View File

@ -1,49 +1,46 @@
class NewsArticleModel { import 'article_model.dart';
const NewsArticleModel({
required this.id,
required this.title,
required this.description,
required this.content,
required this.category,
required this.createdAt,
this.sourceName,
this.image,
this.url,
});
final int id; class NewsArticleModel extends ArticleModel {
final String title; const NewsArticleModel({
final String description; required super.id,
final String content; required super.title,
final String category; required super.description,
final String createdAt; required super.content,
final String? sourceName; required super.category,
final String? image; required String createdAt,
final String? url; String? sourceName,
String? image,
String? url,
super.author,
}) : super(
publishedAt: createdAt,
source: sourceName,
imageUrl: image,
articleUrl: url,
);
String get createdAt => publishedAt;
String? get sourceName => source;
String? get image => imageUrl;
String? get url => articleUrl;
factory NewsArticleModel.fromJson(Map<String, dynamic> json) { factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
final article = ArticleModel.fromJson(json);
return NewsArticleModel.fromArticle(article);
}
factory NewsArticleModel.fromArticle(ArticleModel article) {
return NewsArticleModel( return NewsArticleModel(
id: (json['id'] as num?)?.toInt() ?? 0, id: article.id,
title: json['title'] as String? ?? '-', title: article.title,
description: description: article.description,
(json['description'] as String? ?? json['excerpt'] as String? ?? '-') content: article.content,
.trim(), category: article.category,
content: (json['content'] as String? ?? '').trim(), createdAt: article.publishedAt,
category: (json['category'] as String? ?? 'Nutrisi Anak').trim(), sourceName: article.source,
createdAt: image: article.imageUrl,
(json['created_at'] as String? ?? json['published_at'] as String? ?? '') url: article.articleUrl,
.trim(), author: article.author,
sourceName: (json['source_name'] as String? ?? '').trim().isEmpty
? null
: json['source_name'] as String?,
image: (json['image'] as String? ?? json['image_url'] as String? ?? '')
.trim()
.isEmpty
? null
: (json['image'] as String? ?? json['image_url'] as String?),
url: (json['url'] as String? ?? '').trim().isEmpty
? null
: json['url'] as String?,
); );
} }
} }

View File

@ -0,0 +1,56 @@
class NotificationModel {
const NotificationModel({
required this.id,
required this.type,
required this.title,
required this.description,
required this.childName,
required this.priority,
required this.time,
required this.isRead,
});
final int id;
final String type;
final String title;
final String description;
final String childName;
final String priority;
final String time;
final bool isRead;
factory NotificationModel.fromJson(Map<String, dynamic> json) {
return NotificationModel(
id: _int(json['id']),
type: _string(json['type'], fallback: 'info'),
title: _string(json['title'], fallback: 'Notifikasi'),
description: _string(json['description'] ?? json['message']),
childName: _string(json['child_name']),
priority: _string(json['priority'], fallback: 'Sedang'),
time: _string(json['time'], fallback: '-'),
isRead: json['is_read'] == true,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'title': title,
'description': description,
'child_name': childName,
'priority': priority,
'time': time,
'is_read': isRead,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,29 @@
class NutritionistNoteModel {
const NutritionistNoteModel({
required this.id,
required this.childId,
required this.note,
required this.createdAt,
});
final int id;
final int childId;
final String note;
final String createdAt;
factory NutritionistNoteModel.fromJson(Map<String, dynamic> json) {
return NutritionistNoteModel(
id: (json['id'] as num?)?.toInt() ?? 0,
childId: (json['child_id'] as num?)?.toInt() ?? 0,
note: json['note'] as String? ?? '',
createdAt: json['created_at'] as String? ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'child_id': childId,
'note': note,
'created_at': createdAt,
};
}

View File

@ -0,0 +1,56 @@
class NutritionistProfileModel {
const NutritionistProfileModel({
required this.id,
required this.name,
required this.phone,
required this.email,
required this.profession,
required this.workplace,
this.photo,
required this.isActive,
});
final int id;
final String name;
final String phone;
final String email;
final String profession;
final String workplace;
final String? photo;
final bool isActive;
factory NutritionistProfileModel.fromJson(Map<String, dynamic> json) {
return NutritionistProfileModel(
id: _int(json['id']),
name: _string(json['name'], fallback: 'Ahli Gizi'),
phone: _string(json['phone'], fallback: '-'),
email: _string(json['email'], fallback: '-'),
profession: _string(json['profession'], fallback: 'Ahli Gizi'),
workplace: _string(json['workplace'], fallback: '-'),
photo: json['photo'] as String?,
isActive: json['is_active'] != false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'phone': phone,
'email': email,
'profession': profession,
'workplace': workplace,
'photo': photo,
'is_active': isActive,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
int _int(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}

View File

@ -28,10 +28,10 @@ class RecommendationResponseModel {
json['resolved_status'] as String? ?? json['resolved_status'] as String? ??
json['status'] as String? ?? json['status'] as String? ??
'-', '-',
primaryCategory: normalized['primary_category'] as String? ?? 'Normal', primaryCategory: normalized['primary_category'] as String? ?? 'Gizi Baik',
matchedCategories: rawCategories is List matchedCategories: rawCategories is List
? rawCategories.whereType<String>().toList() ? rawCategories.whereType<String>().toList()
: const ['Normal'], : const ['Gizi Baik'],
items: (json['data'] as List<dynamic>? ?? const []) items: (json['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map(RekomendasiModel.fromJson) .map(RekomendasiModel.fromJson)

View File

@ -12,6 +12,13 @@ class RiwayatResponseModel {
.map(RiwayatItemModel.fromJson) .map(RiwayatItemModel.fromJson)
.toList() .toList()
: <RiwayatItemModel>[]; : <RiwayatItemModel>[];
items.sort((a, b) {
final ad = DateTime.tryParse(a.tanggalUkur) ?? DateTime(1900);
final bd = DateTime.tryParse(b.tanggalUkur) ?? DateTime(1900);
final dateCompare = ad.compareTo(bd);
if (dateCompare != 0) return dateCompare;
return a.id.compareTo(b.id);
});
return RiwayatResponseModel( return RiwayatResponseModel(
child: ChildInfoModel.fromJson( child: ChildInfoModel.fromJson(
@ -28,14 +35,12 @@ class ChildInfoModel {
required this.nama, required this.nama,
required this.tanggalLahir, required this.tanggalLahir,
required this.jenisKelamin, required this.jenisKelamin,
this.photoUrl,
}); });
final int id; final int id;
final String nama; final String nama;
final String tanggalLahir; final String tanggalLahir;
final String jenisKelamin; final String jenisKelamin;
final String? photoUrl;
factory ChildInfoModel.fromJson(Map<String, dynamic> json) { factory ChildInfoModel.fromJson(Map<String, dynamic> json) {
return ChildInfoModel( return ChildInfoModel(
@ -43,10 +48,6 @@ class ChildInfoModel {
nama: json['nama'] as String? ?? '-', nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] as String? ?? '-', tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
jenisKelamin: json['jenis_kelamin'] as String? ?? '-', jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
photoUrl:
json['photo_url'] as String? ??
json['foto_url'] as String? ??
json['avatar_url'] as String?,
); );
} }
} }
@ -84,6 +85,8 @@ class RiwayatItemModel {
required this.zBbu, required this.zBbu,
required this.zTbu, required this.zTbu,
required this.zBbtb, required this.zBbtb,
this.isAnomaly = false,
this.dataStatus = 'normal',
}); });
final int id; final int id;
@ -97,6 +100,8 @@ class RiwayatItemModel {
final double? zBbu; final double? zBbu;
final double? zTbu; final double? zTbu;
final double? zBbtb; final double? zBbtb;
final bool isAnomaly;
final String dataStatus;
factory RiwayatItemModel.fromJson(Map<String, dynamic> json) { factory RiwayatItemModel.fromJson(Map<String, dynamic> json) {
final z = json['z_score'] as Map<String, dynamic>? ?? const {}; final z = json['z_score'] as Map<String, dynamic>? ?? const {};
@ -115,6 +120,8 @@ class RiwayatItemModel {
zBbu: parseNullableNum(z['bbu']), zBbu: parseNullableNum(z['bbu']),
zTbu: parseNullableNum(z['tbu']), zTbu: parseNullableNum(z['tbu']),
zBbtb: parseNullableNum(z['bbtb']), zBbtb: parseNullableNum(z['bbtb']),
isAnomaly: json['is_anomaly'] == true,
dataStatus: json['data_status'] as String? ?? 'normal',
); );
} }
} }

View File

@ -0,0 +1,48 @@
class ZScoreResultModel {
const ZScoreResultModel({
required this.bbuScore,
required this.bbuStatus,
required this.tbuScore,
required this.tbuStatus,
required this.bbtbScore,
required this.bbtbStatus,
});
final double bbuScore;
final String bbuStatus;
final double tbuScore;
final String tbuStatus;
final double bbtbScore;
final String bbtbStatus;
factory ZScoreResultModel.fromJson(Map<String, dynamic> json) {
return ZScoreResultModel(
bbuScore: _double(json['bbu_score']),
bbuStatus: _string(json['bbu_status'], fallback: '-'),
tbuScore: _double(json['tbu_score']),
tbuStatus: _string(json['tbu_status'], fallback: '-'),
bbtbScore: _double(json['bbtb_score']),
bbtbStatus: _string(json['bbtb_status'], fallback: '-'),
);
}
Map<String, dynamic> toJson() => {
'bbu_score': bbuScore,
'bbu_status': bbuStatus,
'tbu_score': tbuScore,
'tbu_status': tbuStatus,
'bbtb_score': bbtbScore,
'bbtb_status': bbtbStatus,
};
}
String _string(dynamic value, {String fallback = ''}) {
if (value is String && value.trim().isNotEmpty) return value.trim();
return fallback;
}
double _double(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0;
return 0;
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/services/auth_service.dart';
class AuthProvider extends ChangeNotifier {
AuthProvider({AuthService? service}) : _service = service ?? AuthService();
final AuthService _service;
bool isLoading = false;
Future<void> logout() async {
isLoading = true;
notifyListeners();
await _service.logout();
isLoading = false;
notifyListeners();
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/child_detail_model.dart';
import 'package:s_gizi/services/child_detail_service.dart';
class ChildDetailProvider extends ChangeNotifier {
ChildDetailProvider({ChildDetailService? service})
: _service = service ?? ChildDetailService();
final ChildDetailService _service;
bool isLoading = false;
String? errorMessage;
ChildDetailModel? childDetail;
Future<void> fetchChildDetail(int childId) async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
childDetail = await _service.getChildDetail(childId);
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
void clearError() {
errorMessage = null;
notifyListeners();
}
}

View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/child_monitoring_model.dart';
import 'package:s_gizi/services/child_monitoring_service.dart';
class ChildMonitoringProvider extends ChangeNotifier {
ChildMonitoringProvider({ChildMonitoringService? service})
: _service = service ?? ChildMonitoringService();
final ChildMonitoringService _service;
Timer? _debounce;
bool isLoading = false;
bool isRefreshing = false;
String? errorMessage;
ChildMonitoringSummary summary = ChildMonitoringSummary.empty;
List<ChildMonitoringModel> children = const [];
String selectedFilter = 'all';
String searchQuery = '';
Future<void> fetchChildren() async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
final response = await _service.getChildren(
search: searchQuery,
filter: selectedFilter,
);
summary = response.summary;
children = response.children;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> refreshChildren() async {
isRefreshing = true;
errorMessage = null;
notifyListeners();
try {
final response = await _service.getChildren(
search: searchQuery,
filter: selectedFilter,
);
summary = response.summary;
children = response.children;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isRefreshing = false;
notifyListeners();
}
}
void setFilter(String value) {
if (selectedFilter == value) return;
selectedFilter = value;
fetchChildren();
}
void setSearchQuery(String value) {
searchQuery = value;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), fetchChildren);
}
void clearError() {
errorMessage = null;
notifyListeners();
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
}

View File

@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/chat_message_model.dart';
import 'package:s_gizi/models/child_chat_detail_model.dart';
import 'package:s_gizi/models/consultation_model.dart';
import 'package:s_gizi/services/consultation_service.dart';
class ConsultationProvider extends ChangeNotifier {
ConsultationProvider({ConsultationService? service})
: _service = service ?? ConsultationService();
final ConsultationService _service;
Timer? _debounce;
bool isLoading = false;
bool isSending = false;
String? errorMessage;
String searchQuery = '';
String selectedFilter = 'all';
List<ConsultationModel> consultations = const [];
List<ChatMessageModel> messages = const [];
ChildChatDetailModel? childDetail;
Future<void> fetchConsultations() async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
consultations = await _service.getConsultations(
search: searchQuery,
filter: selectedFilter,
);
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
void setSearchQuery(String value) {
searchQuery = value;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), fetchConsultations);
}
void setFilter(String value) {
selectedFilter = value;
fetchConsultations();
}
Future<void> fetchMessages(int consultationId) async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
messages = await _service.getChatMessages(consultationId);
childDetail = await _service.getChildDetailFromChat(consultationId);
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<bool> sendMessage(int consultationId, String message) async {
if (message.trim().isEmpty) return false;
isSending = true;
errorMessage = null;
notifyListeners();
try {
await _service.sendMessage(
consultationId: consultationId,
message: message.trim(),
);
messages = await _service.getChatMessages(consultationId);
return true;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
return false;
} finally {
isSending = false;
notifyListeners();
}
}
Future<bool> closeConsultation(int consultationId) async {
try {
await _service.closeConsultation(consultationId);
return true;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
notifyListeners();
return false;
}
}
Future<bool> saveNote({
required int consultationId,
required String category,
required String note,
}) async {
try {
await _service.saveNote(
consultationId: consultationId,
category: category,
note: note,
);
childDetail = await _service.getChildDetailFromChat(consultationId);
notifyListeners();
return true;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
notifyListeners();
return false;
}
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/notification_model.dart';
import 'package:s_gizi/services/notification_service.dart';
class NotificationProvider extends ChangeNotifier {
NotificationProvider({NotificationService? service})
: _service = service ?? NotificationService();
final NotificationService _service;
bool isLoading = false;
String? errorMessage;
String selectedFilter = 'all';
List<NotificationModel> notifications = const [];
Future<void> fetchNotifications() async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
notifications = await _service.getNotifications(filter: selectedFilter);
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
void setFilter(String filter) {
selectedFilter = filter;
fetchNotifications();
}
Future<void> markRead(int id) async {
await _service.markRead(id);
notifications = [
for (final item in notifications)
item.id == id
? NotificationModel.fromJson({...item.toJson(), 'is_read': true})
: item,
];
notifyListeners();
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/dashboard_nutritionist_model.dart';
import 'package:s_gizi/services/nutritionist_dashboard_service.dart';
class NutritionistDashboardProvider extends ChangeNotifier {
NutritionistDashboardProvider({NutritionistDashboardService? service})
: _service = service ?? NutritionistDashboardService();
final NutritionistDashboardService _service;
bool isLoading = false;
bool isRefreshing = false;
String? errorMessage;
DashboardNutritionistModel? dashboardData;
Future<void> fetchDashboard() async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
dashboardData = await _service.getDashboard();
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> refreshDashboard() async {
isRefreshing = true;
errorMessage = null;
notifyListeners();
try {
dashboardData = await _service.getDashboard();
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isRefreshing = false;
notifyListeners();
}
}
}

View File

@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/services/nutritionist_note_service.dart';
class NutritionistNoteProvider extends ChangeNotifier {
NutritionistNoteProvider({NutritionistNoteService? service})
: _service = service ?? NutritionistNoteService();
final NutritionistNoteService _service;
bool isSaving = false;
String? errorMessage;
Future<bool> saveNote({required int childId, required String note}) async {
isSaving = true;
errorMessage = null;
notifyListeners();
try {
await _service.saveNote(childId: childId, note: note);
return true;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
return false;
} finally {
isSaving = false;
notifyListeners();
}
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/models/nutritionist_profile_model.dart';
import 'package:s_gizi/services/profile_service.dart';
class ProfileProvider extends ChangeNotifier {
ProfileProvider({ProfileService? service})
: _service = service ?? ProfileService();
final ProfileService _service;
bool isLoading = false;
bool isSaving = false;
String? errorMessage;
NutritionistProfileModel? profile;
Future<void> fetchProfile() async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
profile = await _service.getProfile();
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> updateStatus(bool active) async {
isSaving = true;
notifyListeners();
try {
profile = await _service.updateStatus(active);
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
} finally {
isSaving = false;
notifyListeners();
}
}
}

View File

@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:s_gizi/services/quick_validation_service.dart';
class QuickValidationProvider extends ChangeNotifier {
QuickValidationProvider({QuickValidationService? service})
: _service = service ?? QuickValidationService();
final QuickValidationService _service;
bool isSaving = false;
String? errorMessage;
Future<bool> validate({
required int measurementId,
required bool accepted,
String? note,
}) async {
isSaving = true;
errorMessage = null;
notifyListeners();
try {
await _service.validateMeasurement(
measurementId: measurementId,
accepted: accepted,
note: note,
);
return true;
} catch (e) {
errorMessage = e.toString().replaceFirst('Exception: ', '');
return false;
} finally {
isSaving = false;
notifyListeners();
}
}
}

View File

@ -44,6 +44,7 @@ class _AddChildScreenState extends State<AddChildScreen>
void initState() { void initState() {
super.initState(); super.initState();
_selectedExistingChildId = _appState.activeChildId; _selectedExistingChildId = _appState.activeChildId;
_nameController.addListener(_refreshAvatar);
_shakeController = AnimationController( _shakeController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 450), duration: const Duration(milliseconds: 450),
@ -52,12 +53,17 @@ class _AddChildScreenState extends State<AddChildScreen>
@override @override
void dispose() { void dispose() {
_nameController.removeListener(_refreshAvatar);
_nameController.dispose(); _nameController.dispose();
_dateController.dispose(); _dateController.dispose();
_shakeController.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
void _refreshAvatar() {
if (mounted) setState(() {});
}
bool get _isValid { bool get _isValid {
return _nameController.text.trim().isNotEmpty && return _nameController.text.trim().isNotEmpty &&
_birthDate != null && _birthDate != null &&
@ -110,9 +116,9 @@ class _AddChildScreenState extends State<AddChildScreen>
builder: (context, child) { builder: (context, child) {
return Theme( return Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith( colorScheme: Theme.of(
primary: const Color(0xFF0B7A86), context,
), ).colorScheme.copyWith(primary: const Color(0xFF0B7A86)),
), ),
child: child!, child: child!,
); );
@ -180,12 +186,16 @@ class _AddChildScreenState extends State<AddChildScreen>
child: child, child: child,
); );
}, },
child: HealthCard( child:
HealthCard(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18), padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Detail Data Anak', style: AppTypography.h2), Text(
'Detail Data Anak',
style: AppTypography.h2,
),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'Lengkapi informasi untuk analisis gizi tepat.', 'Lengkapi informasi untuk analisis gizi tepat.',
@ -195,74 +205,24 @@ class _AddChildScreenState extends State<AddChildScreen>
Center( Center(
child: Column( child: Column(
children: [ children: [
Stack( SgAvatar(
clipBehavior: Clip.none, name: _nameController.text,
children: [ gender: _gender,
Container( radius: 55,
width: 110, icon: Icons.child_care_rounded,
height: 110, )
decoration: BoxDecoration( .animate(
color: const Color(0xFFEFF9F8), onPlay: (c) =>
shape: BoxShape.circle, c.repeat(reverse: true),
border: Border.all( )
color: const Color(0xFF0B7A86), .scale(
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), begin: const Offset(0.98, 0.98),
end: const Offset(1.02, 1.02), end: const Offset(1.02, 1.02),
duration: 2.seconds, duration: 2.seconds,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'Unggah Foto Anak', getInitialName(_nameController.text),
style: AppTypography.h3.copyWith( style: AppTypography.h3.copyWith(
color: const Color(0xFF0B7A86), color: const Color(0xFF0B7A86),
), ),
@ -283,7 +243,8 @@ class _AddChildScreenState extends State<AddChildScreen>
hint: 'Contoh: Arkan Syahputra', hint: 'Contoh: Arkan Syahputra',
icon: PhosphorIconsRegular.user, icon: PhosphorIconsRegular.user,
showError: showError:
_showValidation && _nameController.text.trim().isEmpty, _showValidation &&
_nameController.text.trim().isEmpty,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -295,7 +256,8 @@ class _AddChildScreenState extends State<AddChildScreen>
child: _GenderOption( child: _GenderOption(
label: 'Laki-laki', label: 'Laki-laki',
active: _gender == 'L', active: _gender == 'L',
onTap: () => setState(() => _gender = 'L'), onTap: () =>
setState(() => _gender = 'L'),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -303,7 +265,8 @@ class _AddChildScreenState extends State<AddChildScreen>
child: _GenderOption( child: _GenderOption(
label: 'Perempuan', label: 'Perempuan',
active: _gender == 'P', active: _gender == 'P',
onTap: () => setState(() => _gender = 'P'), onTap: () =>
setState(() => _gender = 'P'),
), ),
), ),
], ],
@ -328,13 +291,14 @@ class _AddChildScreenState extends State<AddChildScreen>
decoration: _inputDecoration( decoration: _inputDecoration(
hint: 'Pilih tanggal lahir', hint: 'Pilih tanggal lahir',
icon: LucideIcons.calendarDays, icon: LucideIcons.calendarDays,
showError: _showValidation && _birthDate == null, showError:
_showValidation && _birthDate == null,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (_birthDate != null) if (_birthDate != null)
Text( Text(
'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!))}', 'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!), source: 'add_child_birthdate_preview')}',
style: AppTypography.caption.copyWith( style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86), color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -357,10 +321,10 @@ class _AddChildScreenState extends State<AddChildScreen>
), ),
], ],
), ),
).animate().fadeIn(delay: 100.ms, duration: 320.ms).slideY( )
begin: 0.1, .animate()
end: 0, .fadeIn(delay: 100.ms, duration: 320.ms)
), .slideY(begin: 0.1, end: 0),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
HealthCard( HealthCard(
@ -472,7 +436,9 @@ class _GenderOption extends StatelessWidget {
height: 44, height: 44,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? const Color(0xFF0B7A86).withValues(alpha: 0.10) : Colors.white, color: active
? const Color(0xFF0B7A86).withValues(alpha: 0.10)
: Colors.white,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all( border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4), color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4),
@ -546,10 +512,15 @@ class _SaveButtonState extends State<_SaveButton> {
children: [ children: [
Text( Text(
'Simpan & Lanjutkan', 'Simpan & Lanjutkan',
style: AppTypography.h2.copyWith(color: Colors.white), style: AppTypography.h2.copyWith(
color: Colors.white,
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
const Icon(LucideIcons.arrowRight, color: Colors.white), const Icon(
LucideIcons.arrowRight,
color: Colors.white,
),
], ],
), ),
), ),
@ -557,11 +528,9 @@ class _SaveButtonState extends State<_SaveButton> {
), ),
), ),
), ),
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY( )
begin: 0, .animate(onPlay: (c) => c.repeat(reverse: true))
end: -2, .moveY(begin: 0, end: -2, duration: 1800.ms);
duration: 1800.ms,
);
} }
} }
@ -583,7 +552,7 @@ class _ChildSelectorRow extends StatelessWidget {
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: children.length + 1, itemCount: children.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 10), separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == children.length) { if (index == children.length) {
return Container( return Container(
@ -618,7 +587,9 @@ class _ChildSelectorRow extends StatelessWidget {
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all( border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2EAE7), color: active
? const Color(0xFF0B7A86)
: const Color(0xFFE2EAE7),
width: active ? 1.8 : 1, width: active ? 1.8 : 1,
), ),
boxShadow: [ boxShadow: [
@ -636,7 +607,6 @@ class _ChildSelectorRow extends StatelessWidget {
ChildAvatar( ChildAvatar(
name: child.nama, name: child.nama,
gender: child.jenisKelamin, gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 22, radius: 22,
), ),
if (active) if (active)
@ -665,7 +635,10 @@ class _ChildSelectorRow extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( Text(
formatAgeFromBirthDate(child.tanggalLahir), formatAgeFromBirthDate(
child.tanggalLahir,
source: 'add_child_existing_child_card',
),
style: AppTypography.caption, style: AppTypography.caption,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,

View File

@ -1,152 +0,0 @@
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 'consultation_chat_screen.dart';
import 'home_screen.dart';
import 'nutrition_screen.dart';
import 'profile_screen.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int _index = 0;
late final List<Widget> _screens = [
HomeScreen(onChangeTab: _setTab),
const NutritionScreen(),
const ConsultationChatScreen(showAppBar: false),
const ProfileScreen(),
];
void _setTab(int index) => setState(() => _index = index);
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
child: KeyedSubtree(key: ValueKey(_index), child: _screens[_index]),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, -6),
),
],
),
padding: const EdgeInsets.fromLTRB(14, 8, 14, 10),
child: SafeArea(
top: false,
child: Row(
children: [
_NavItem(
label: 'Home',
active: _index == 0,
icon: PhosphorIconsRegular.house,
onTap: () => _setTab(0),
),
_NavItem(
label: 'Nutrisi',
active: _index == 1,
icon: LucideIcons.apple,
onTap: () => _setTab(1),
),
_NavItem(
label: 'Konsultasi',
active: _index == 2,
icon: PhosphorIconsRegular.chatCircleDots,
onTap: () => _setTab(2),
),
_NavItem(
label: 'Profil',
active: _index == 3,
icon: PhosphorIconsRegular.user,
onTap: () => _setTab(3),
),
],
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.label,
required this.active,
required this.icon,
required this.onTap,
});
final String label;
final bool active;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: active ? const Color(0xFFEAF8F7) : Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedScale(
scale: active ? 1.08 : 1,
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
color: active ? const Color(0xFF0B7A86) : const Color(0xFF8B959C),
),
),
const SizedBox(height: 3),
Text(
label,
style: AppTypography.caption.copyWith(
color: active ? const Color(0xFF0B7A86) : const Color(0xFF8B959C),
fontWeight: active ? FontWeight.w800 : FontWeight.w600,
),
),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
height: 3,
width: active ? 20 : 0,
decoration: BoxDecoration(
color: const Color(0xFF0B7A86),
borderRadius: BorderRadius.circular(99),
),
),
],
),
).animate(target: active ? 1 : 0).scale(
begin: const Offset(0.98, 0.98),
end: const Offset(1, 1),
duration: 220.ms,
),
),
);
}
}

View File

@ -1,141 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app_design.dart';
import '../models/news_article_model.dart';
class ArticleDetailScreen extends StatelessWidget {
const ArticleDetailScreen({
super.key,
required this.article,
required this.related,
});
final NewsArticleModel article;
final List<NewsArticleModel> related;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(title: const Text('Detail Artikel')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'article-${article.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: _ArticleImage(imageUrl: article.image, fallbackIndex: article.id),
),
),
const SizedBox(height: 14),
StatusBadge(text: article.category, color: SgColors.primary),
const SizedBox(height: 10),
Text(article.title, style: AppTypography.h2),
const SizedBox(height: 6),
Text(
[
if (article.sourceName != null && article.sourceName!.isNotEmpty)
article.sourceName,
if (article.createdAt.isNotEmpty) article.createdAt,
].whereType<String>().join(''),
style: AppTypography.caption,
),
const SizedBox(height: 14),
Text(article.description, style: AppTypography.body),
const SizedBox(height: 12),
Text(
article.content.trim().isEmpty ? article.description : article.content,
style: AppTypography.body.copyWith(color: SgColors.textPrimary),
),
if (article.url != null && article.url!.isNotEmpty) ...[
const SizedBox(height: 16),
PrimaryButton(
label: 'Buka Sumber (Selengkapnya)',
icon: Icons.open_in_new_rounded,
onPressed: () async {
final uri = Uri.tryParse(article.url!);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
},
),
],
const SizedBox(height: 20),
Text('Artikel Terkait', style: AppTypography.h3),
const SizedBox(height: 10),
...related
.where((item) => item.id != article.id)
.take(3)
.map(
(item) => HealthCard(
margin: const EdgeInsets.only(bottom: 10),
onTap: () => Navigator.of(context).push(
fadeRoute(ArticleDetailScreen(article: item, related: related)),
),
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _ArticleImage(
imageUrl: item.image,
fallbackIndex: item.id,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
],
),
),
),
);
}
}
class _ArticleImage extends StatelessWidget {
const _ArticleImage({required this.imageUrl, required this.fallbackIndex});
final String? imageUrl;
final int fallbackIndex;
@override
Widget build(BuildContext context) {
final fallback = _assetByIndex(fallbackIndex);
if (imageUrl == null || imageUrl!.isEmpty) {
return Image.asset(fallback, fit: BoxFit.cover);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Image.asset(fallback, fit: BoxFit.cover),
placeholder: (_, __) => Container(color: const Color(0xFFEAF1EF)),
);
}
}
String _assetByIndex(int index) {
const images = [
'assets/image/onboarding_food.png',
'assets/image/onboarding_monitoring.png',
'assets/image/onboarding_consultation.png',
];
return images[index % images.length];
}

View File

@ -1,214 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../models/news_article_model.dart';
import 'article_detail_screen.dart';
class ArticlesScreen extends StatefulWidget {
const ArticlesScreen({
super.key,
required this.title,
required this.articles,
this.initialCategory = 'Semua',
});
final String title;
final List<NewsArticleModel> articles;
final String initialCategory;
@override
State<ArticlesScreen> createState() => _ArticlesScreenState();
}
class _ArticlesScreenState extends State<ArticlesScreen> {
late String _activeCategory = widget.initialCategory;
@override
Widget build(BuildContext context) {
final filtered = widget.articles
.where(
(a) =>
_activeCategory == 'Semua' ||
a.category.toLowerCase().contains(_activeCategory.toLowerCase()),
)
.toList();
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(
title: Text(widget.title),
backgroundColor: SgColors.background,
),
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemBuilder: (_, index) {
final label = _nutritionCategories[index];
final isActive = label == _activeCategory;
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => setState(() => _activeCategory = label),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF0B7A86) : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color:
isActive ? const Color(0xFF0B7A86) : SgColors.border,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: Text(
label,
style: AppTypography.caption.copyWith(
color: isActive ? Colors.white : SgColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
);
},
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemCount: _nutritionCategories.length,
),
),
const SizedBox(height: 16),
if (filtered.isEmpty)
const EmptyState(
title: 'Belum Ada Artikel',
message: 'Tidak ada artikel untuk kategori ini.',
)
else
...filtered.map(
(article) => HealthCard(
margin: const EdgeInsets.only(bottom: 12),
padding: EdgeInsets.zero,
onTap: () {
Navigator.of(context).push(
fadeRoute(
ArticleDetailScreen(
article: article,
related: widget.articles,
),
),
);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'article-${article.id}',
child: ClipRRect(
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(20),
),
child: SizedBox(
width: 104,
height: 96,
child: Image.asset(
_articleAssetByIndex(article.id),
fit: BoxFit.cover,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF7FD6C2)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(999),
),
child: Text(
article.category,
style: AppTypography.caption.copyWith(
color: const Color(0xFF085B63),
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
Text(
article.title,
style: AppTypography.h3.copyWith(
color: SgColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
article.description,
style: AppTypography.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
),
),
),
],
),
),
),
);
}
}
const List<String> _nutritionCategories = [
'Semua',
'Stunting',
'MPASI',
'Protein',
'Vitamin',
'Gizi Seimbang',
];
String _articleAssetByIndex(int index) {
const assets = [
'assets/image/onboarding_food.png',
'assets/image/onboarding_monitoring.png',
'assets/image/onboarding_consultation.png',
];
return assets[index % assets.length];
}

View File

@ -1,145 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
import 'check_child_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _api = ApiService();
final _phoneController = TextEditingController(text: '+62');
final _otpController = TextEditingController();
bool _otpSent = false;
bool _loading = false;
String? _error;
@override
void dispose() {
_phoneController.dispose();
_otpController.dispose();
super.dispose();
}
Future<void> _sendOtp() async {
await _guard(() async {
await _api.sendOtp(_phoneController.text);
setState(() => _otpSent = true);
});
}
Future<void> _verifyOtp() async {
await _guard(() async {
final token = await _api.verifyOtp(
_phoneController.text,
_otpController.text,
);
SgiziAppState.instance.setToken(token);
if (!mounted) return;
Navigator.of(context).pushReplacement(fadeRoute(const CheckChildScreen()));
});
}
Future<void> _guard(Future<void> Function() action) async {
setState(() {
_loading = true;
_error = null;
});
try {
await action();
} catch (error) {
setState(() => _error = error.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 24),
const AppLogo(size: 68, showLabel: true),
const SizedBox(height: 40),
const Text('Masuk dengan Nomor HP', style: AppTypography.h1),
const SizedBox(height: 8),
const Text(
'Kami akan mengirim kode OTP untuk menjaga data si Kecil tetap aman.',
style: AppTypography.body,
),
const SizedBox(height: 28),
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+6281234567890',
prefixIcon: Icon(Icons.phone_iphone_rounded),
),
),
if (_otpSent) ...[
const SizedBox(height: 16),
TextField(
controller: _otpController,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: const InputDecoration(
labelText: 'Kode OTP',
hintText: '123456',
prefixIcon: Icon(Icons.lock_outline_rounded),
counterText: '',
),
),
],
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: AppTypography.caption.copyWith(
color: SgColors.danger,
),
),
],
const SizedBox(height: 20),
PrimaryButton(
label: _loading
? 'Memproses...'
: (_otpSent ? 'Verifikasi OTP' : 'Kirim OTP'),
icon: _otpSent
? Icons.verified_user_outlined
: Icons.sms_outlined,
onPressed: _loading
? null
: (_otpSent ? _verifyOtp : _sendOtp),
),
if (_otpSent) ...[
const SizedBox(height: 12),
PrimaryButton(
label: 'Kirim Ulang OTP',
isOutlined: true,
onPressed: _loading ? null : _sendOtp,
),
],
],
),
),
],
),
),
);
}
}

View File

@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
import 'add_child_screen.dart';
import 'app_shell.dart';
class CheckChildScreen extends StatefulWidget {
const CheckChildScreen({super.key});
@override
State<CheckChildScreen> createState() => _CheckChildScreenState();
}
class _CheckChildScreenState extends State<CheckChildScreen> {
final _api = ApiService();
late Future<void> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<void> _load() async {
final children = await _api.getChildren();
SgiziAppState.instance.setChildren(children);
if (!mounted) return;
Navigator.of(context).pushReplacement(
fadeRoute(
children.isEmpty
? const AddChildScreen(isFirstSetup: true)
: const AppShell(),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
body: FutureBuilder<void>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return ErrorState(
message:
'Data anak belum dapat dimuat. Coba ulangi koneksi ke server.',
onRetry: () => setState(() => _future = _load()),
);
}
return const Center(
child: CircularProgressIndicator(color: SgColors.primary),
);
},
),
);
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../app_design.dart';
import 'add_child_screen.dart';
class ChildEmptyStateScreen extends StatelessWidget {
const ChildEmptyStateScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(SgSpacing.pageH + 4),
child: Column(
children: [
const Spacer(),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/image/onboarding_monitoring.png',
width: 140,
height: 140,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: const Color(0xFFE8F7F1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
LucideIcons.baby,
size: 56,
color: Color(0xFF0B7A86),
),
),
),
).animate().fadeIn(duration: 320.ms).scale(begin: const Offset(0.92, 0.92)),
const SizedBox(height: 20),
Text(
'Belum ada data anak',
textAlign: TextAlign.center,
style: AppTypography.h1.copyWith(fontSize: 24),
).animate().fadeIn(delay: 80.ms),
const SizedBox(height: 8),
const Text(
'Tambahkan data si kecil untuk mulai memantau pertumbuhan dan status gizinya.',
textAlign: TextAlign.center,
style: AppTypography.body,
).animate().fadeIn(delay: 140.ms),
const Spacer(),
PrimaryButton(
label: 'Tambah Data Anak',
icon: Icons.add_rounded,
onPressed: () {
Navigator.of(context).pushReplacement(
fadeRoute(const AddChildScreen(isFirstSetup: true)),
);
},
).animate().fadeIn(delay: 200.ms).slideY(begin: 0.08, end: 0),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,804 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:shimmer/shimmer.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../models/mobile_child_model.dart';
import '../models/news_article_model.dart';
import '../models/riwayat_response_model.dart';
import '../services/api_service.dart';
import '../utils/nutrition_display_utils.dart';
import 'article_detail_screen.dart';
import 'children_screen.dart';
import 'consultation_chat_screen.dart';
import 'input_screen.dart';
import 'recommendation_screen.dart';
import 'riwayat_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.onChangeTab});
final ValueChanged<int> onChangeTab;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ApiService _apiService = ApiService();
final SgiziAppState _appState = SgiziAppState.instance;
late Future<_HomeDashboardData> _future;
late Future<List<NewsArticleModel>> _articlesFuture;
@override
void initState() {
super.initState();
_appState.addListener(_handleState);
_future = _load();
_articlesFuture = _loadArticles();
}
@override
void dispose() {
_appState.removeListener(_handleState);
super.dispose();
}
void _handleState() => setState(() {
_future = _load();
_articlesFuture = _loadArticles();
});
void _retry() => setState(() {
_future = _load();
_articlesFuture = _loadArticles();
});
Future<_HomeDashboardData> _load() async {
final child = _appState.activeChild;
if (child == null) {
return const _HomeDashboardData(child: null, history: null);
}
final history = await _apiService.getRiwayat(childId: child.id);
return _HomeDashboardData(child: child, history: history);
}
Future<List<NewsArticleModel>> _loadArticles() async {
// Dashboard artikel: gabungan DB admin + online (Google News).
final db = await _apiService.getArticlesDb().catchError((_) => <NewsArticleModel>[]);
final news = await _apiService.getNewsArticles().catchError((_) => <NewsArticleModel>[]);
final merged = <NewsArticleModel>[];
final seen = <String>{};
void addAll(List<NewsArticleModel> items) {
for (final a in items) {
final key = '${a.title}__${a.category}'.toLowerCase().trim();
if (key.isEmpty || seen.contains(key)) continue;
seen.add(key);
merged.add(a);
}
}
// Prioritaskan artikel dari DB admin, lalu tambah dari online.
addAll(db);
addAll(news);
return merged;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
body: SafeArea(
child: FutureBuilder<_HomeDashboardData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _HomeSkeleton();
}
if (snapshot.hasError) {
return _HomeError(onRetry: _retry);
}
final data = snapshot.data!;
final child = data.child;
final latest = data.latestMeasurement;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _TopBar(),
const SizedBox(height: 12),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFFE2E8E6),
),
const SizedBox(height: 14),
Text(
'Halo, Bunda 👋',
style: AppTypography.h1.copyWith(fontSize: 34),
),
const SizedBox(height: 6),
const Text(
'Ayo pantau tumbuh kembang si kecil hari ini.',
style: AppTypography.body,
),
const SizedBox(height: 16),
_ActiveChildCard(
child: child,
onTap: () =>
Navigator.of(context).push(fadeRoute(const ChildrenScreen())),
),
const SizedBox(height: 16),
if (child == null)
EmptyState(
title: 'Belum Ada Data Anak',
message: 'Tambahkan data anak agar dashboard aktif.',
actionLabel: 'Tambah Data Anak',
onAction: () =>
Navigator.of(context).push(fadeRoute(const ChildrenScreen())),
)
else
_ModernStatusCard(
latest: latest,
onOpen: () => Navigator.of(context).push(
fadeRoute(RiwayatScreen(childId: child.id)),
),
),
const SizedBox(height: 22),
Text(
'Menu Utama',
style: GoogleFonts.montserrat(
fontSize: 27,
fontWeight: FontWeight.w700,
color: SgColors.textPrimary,
),
),
const SizedBox(height: 16),
_MainMenuRow(
onTapInput: () =>
Navigator.of(context).push(fadeRoute(const InputScreen())),
onTapRecommendation: latest == null
? null
: () => Navigator.of(context).push(
fadeRoute(
RecommendationScreen(
childId: child?.id,
riwayatId: latest?.id,
childName: child?.nama,
status: latest?.statusGabungan,
measuredAt: latest?.tanggalUkur,
),
),
),
onTapConsultation: () => Navigator.of(context).push(
fadeRoute(const ConsultationChatScreen()),
),
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: Text(
'Edukasi Si Kecil',
style: GoogleFonts.montserrat(
fontSize: 27,
fontWeight: FontWeight.w700,
color: SgColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () => widget.onChangeTab(1),
child: const Text('Lihat Semua'),
),
],
),
const SizedBox(height: 14),
FutureBuilder<List<NewsArticleModel>>(
future: _articlesFuture,
builder: (context, articleSnapshot) {
if (articleSnapshot.connectionState == ConnectionState.waiting) {
return const _ArticleSkeletonList();
}
if (articleSnapshot.hasError) {
return _ArticleError(onRetry: _retry);
}
final articles = articleSnapshot.data ?? const [];
if (articles.isEmpty) {
return _ArticleEmpty(onRetry: _retry);
}
return SizedBox(
height: 330,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: articles.length > 8 ? 8 : articles.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
return _ArticleCard(
article: articles[index],
related: articles,
index: index,
);
},
),
);
},
),
],
),
);
},
),
),
);
}
}
class _HomeDashboardData {
const _HomeDashboardData({
required this.child,
required this.history,
});
final MobileChildModel? child;
final RiwayatResponseModel? history;
RiwayatItemModel? get latestMeasurement {
final records = history?.riwayat;
if (records == null || records.isEmpty) return null;
return records.first;
}
}
class _TopBar extends StatelessWidget {
const _TopBar();
@override
Widget build(BuildContext context) {
return Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.asset(
'assets/image/logo_sgizi.png',
width: 66,
height: 66,
fit: BoxFit.cover,
),
),
const Spacer(),
Stack(
children: [
const CircleAvatar(
radius: 22,
backgroundImage: AssetImage('assets/image/onboarding_consultation.png'),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 11,
height: 11,
decoration: BoxDecoration(
color: const Color(0xFF34C759),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
begin: 0,
end: -2,
duration: 1800.ms,
),
],
);
}
}
class _ActiveChildCard extends StatelessWidget {
const _ActiveChildCard({required this.child, required this.onTap});
final MobileChildModel? child;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return HealthCard(
onTap: onTap,
child: Row(
children: [
ChildAvatar(
name: child?.nama ?? 'Anak',
gender: child?.jenisKelamin ?? 'L',
photoUrl: child?.photoUrl,
radius: 28,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
child?.nama ?? 'Belum ada anak aktif',
style: AppTypography.h2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
child == null ? '-' : formatAgeFromBirthDate(child!.tanggalLahir),
style: AppTypography.body,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF0F4F3),
borderRadius: BorderRadius.circular(999),
),
child: const Text('Ganti'),
),
],
),
);
}
}
class _ModernStatusCard extends StatelessWidget {
const _ModernStatusCard({required this.latest, required this.onOpen});
final RiwayatItemModel? latest;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
if (latest == null) {
return HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('STATUS GIZI TERAKHIR'),
const SizedBox(height: 8),
const Text('Belum ada pengukuran tersimpan untuk anak aktif.'),
const SizedBox(height: 14),
PrimaryButton(label: 'Lihat Detail Analisis', onPressed: onOpen),
],
),
);
}
return HealthCard(
color: const Color(0xFFE3F6F8),
borderColor: const Color(0xFFC8ECF0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'UPDATE: ${formatMeasurementDate(latest!.tanggalUkur).toUpperCase()}',
style: AppTypography.caption.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'Status Gizi: ${_shortStatus(latest!.statusGabungan)}',
style: AppTypography.h2,
),
),
],
),
const SizedBox(height: 8),
Text(
_statusDescription(latest!.statusGabungan),
style: AppTypography.body.copyWith(color: const Color(0xFF2E5258)),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _SmallMetric(label: 'Berat', value: '${latest!.berat.toStringAsFixed(1)} kg')),
const SizedBox(width: 8),
Expanded(child: _SmallMetric(label: 'Tinggi', value: '${latest!.tinggi.toStringAsFixed(1)} cm')),
const SizedBox(width: 8),
Expanded(child: _SmallMetric(label: 'Usia', value: formatAgeFromMonths(latest!.umurBulan))),
],
),
const SizedBox(height: 12),
PrimaryButton(
label: 'Lihat Detail Analisis',
icon: LucideIcons.arrowRight,
onPressed: onOpen,
isOutlined: true,
),
],
),
);
}
}
class _SmallMetric extends StatelessWidget {
const _SmallMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.caption),
Text(value, style: AppTypography.h3, overflow: TextOverflow.ellipsis),
],
),
);
}
}
class _MainMenuRow extends StatelessWidget {
const _MainMenuRow({
required this.onTapInput,
required this.onTapRecommendation,
required this.onTapConsultation,
});
final VoidCallback onTapInput;
final VoidCallback? onTapRecommendation;
final VoidCallback onTapConsultation;
@override
Widget build(BuildContext context) {
final showRecommendation = onTapRecommendation != null;
return Row(
children: [
Expanded(
child: _MenuItem(
icon: PhosphorIconsBold.calculator,
label: 'Hitung Gizi',
color: const Color(0xFF77D9E3),
onTap: onTapInput,
),
),
const SizedBox(width: 10),
if (showRecommendation) ...[
Expanded(
child: _MenuItem(
icon: LucideIcons.apple,
label: 'Rekomendasi',
color: const Color(0xFF63D39D),
onTap: onTapRecommendation!,
),
),
const SizedBox(width: 10),
],
Expanded(
child: _MenuItem(
icon: LucideIcons.messageCircle,
label: 'Konsultasi',
color: const Color(0xFFF5A56E),
onTap: onTapConsultation,
),
),
],
);
}
}
class _MenuItem extends StatelessWidget {
const _MenuItem({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Column(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 13,
offset: const Offset(0, 8),
),
],
),
child: Icon(icon, color: Colors.white),
),
const SizedBox(height: 8),
Text(label, style: AppTypography.caption, overflow: TextOverflow.ellipsis),
],
),
);
}
}
class _ArticleCard extends StatelessWidget {
const _ArticleCard({
required this.article,
required this.related,
required this.index,
});
final NewsArticleModel article;
final List<NewsArticleModel> related;
final int index;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 250,
child: HealthCard(
padding: EdgeInsets.zero,
onTap: () => Navigator.of(context).push(
fadeRoute(ArticleDetailScreen(article: article, related: related)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: Hero(
tag: 'article-${article.id}',
child: _CardImage(
imageUrl: article.image,
fallbackIndex: index,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: StatusBadge(
text: article.category,
color: const Color(0xFF0B7A86),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
const SizedBox(height: 4),
Text(
article.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.body,
),
if (article.sourceName != null &&
article.sourceName!.trim().isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Sumber: ${article.sourceName!}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
),
),
],
const SizedBox(height: 8),
Row(
children: [
Text(
'Baca Selengkapnya',
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 6),
const Icon(
LucideIcons.arrowRight,
size: 14,
color: Color(0xFF0B7A86),
),
],
),
],
),
),
],
),
),
);
}
}
class _CardImage extends StatelessWidget {
const _CardImage({required this.imageUrl, required this.fallbackIndex});
final String? imageUrl;
final int fallbackIndex;
@override
Widget build(BuildContext context) {
final fallback = _imageByIndex(fallbackIndex);
if (imageUrl == null || imageUrl!.isEmpty) {
return Image.asset(
fallback,
width: double.infinity,
height: 110,
fit: BoxFit.cover,
);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
width: double.infinity,
height: 110,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: const Color(0xFFEAF1EF)),
errorWidget: (_, __, ___) => Image.asset(
fallback,
width: double.infinity,
height: 110,
fit: BoxFit.cover,
),
);
}
}
class _ArticleSkeletonList extends StatelessWidget {
const _ArticleSkeletonList();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 330,
child: Shimmer.fromColors(
baseColor: const Color(0xFFE8EEEC),
highlightColor: const Color(0xFFF7FAF9),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, __) => Container(
width: 250,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
),
),
),
);
}
}
class _ArticleError extends StatelessWidget {
const _ArticleError({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return HealthCard(
child: Column(
children: [
const Icon(
PhosphorIconsRegular.warningCircle,
size: 32,
color: SgColors.warning,
),
const SizedBox(height: 8),
const Text('Gagal memuat artikel edukasi.'),
const SizedBox(height: 8),
TextButton(onPressed: onRetry, child: const Text('Coba Lagi')),
],
),
);
}
}
class _ArticleEmpty extends StatelessWidget {
const _ArticleEmpty({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return HealthCard(
child: Column(
children: [
Image.asset('assets/image/onboarding_monitoring.png', height: 72, fit: BoxFit.cover),
const SizedBox(height: 8),
const Text('Belum ada artikel edukasi tersedia.'),
const SizedBox(height: 8),
TextButton(onPressed: onRetry, child: const Text('Refresh')),
],
),
);
}
}
class _HomeSkeleton extends StatelessWidget {
const _HomeSkeleton();
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Shimmer.fromColors(
baseColor: const Color(0xFFE8EEEC),
highlightColor: const Color(0xFFF7FAF9),
child: Column(
children: [
Container(height: 44, color: Colors.white),
const SizedBox(height: 12),
Container(height: 90, color: Colors.white),
const SizedBox(height: 12),
Container(height: 180, color: Colors.white),
const SizedBox(height: 12),
Container(height: 80, color: Colors.white),
const SizedBox(height: 12),
Container(height: 220, color: Colors.white),
],
),
),
);
}
}
class _HomeError extends StatelessWidget {
const _HomeError({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return ErrorState(
message: 'Dashboard belum dapat dimuat.',
onRetry: onRetry,
);
}
}
String _shortStatus(String value) {
final lower = value.toLowerCase();
if (lower.contains('normal')) return 'Baik';
if (lower.contains('stunting') || lower.contains('kurang')) return 'Perlu Perhatian';
return value;
}
String _statusDescription(String status) {
final value = status.toLowerCase();
if (value.contains('stunting')) {
return 'Tinggi badan anak masih perlu perhatian. Fokus pada protein hewani, zat besi, dan pemantauan tinggi rutin.';
}
if (value.contains('kurang') || value.contains('underweight')) {
return 'Berat badan anak perlu ditingkatkan. Tambahkan asupan energi dan protein secara bertahap.';
}
if (value.contains('obesitas') || value.contains('lebih')) {
return 'Berat badan perlu dikontrol. Atur porsi seimbang dan perbanyak aktivitas fisik harian anak.';
}
return 'Berdasarkan pengukuran terakhir, tinggi dan berat badan anak sudah sesuai standar WHO.';
}
String _imageByIndex(int index) {
const images = [
'assets/image/onboarding_food.png',
'assets/image/onboarding_monitoring.png',
'assets/image/onboarding_consultation.png',
];
return images[index % images.length];
}

View File

@ -18,7 +18,7 @@ class _InputScreenState extends State<InputScreen> {
final _weightController = TextEditingController(); final _weightController = TextEditingController();
final _heightController = TextEditingController(); final _heightController = TextEditingController();
String _jenisKelamin = 'L'; final String _jenisKelamin = 'L';
String _caraUkur = 'standing'; String _caraUkur = 'standing';
DateTime? _birthDate; DateTime? _birthDate;
DateTime? _measurementDate; DateTime? _measurementDate;

View File

@ -1,177 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../models/api_result_model.dart';
import '../services/api_service.dart';
import 'result_screen.dart';
class LoadingScreen extends StatefulWidget {
const LoadingScreen({super.key, required this.payload});
final Map<String, dynamic> payload;
@override
State<LoadingScreen> createState() => _LoadingScreenState();
}
class _LoadingScreenState extends State<LoadingScreen> {
final ApiService _apiService = ApiService();
late Future<ApiResultModel> _future;
@override
void initState() {
super.initState();
_future = _apiService.postHasil(widget.payload);
}
void _retry() {
setState(() => _future = _apiService.postHasil(widget.payload));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: FutureBuilder<ApiResultModel>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return ErrorState(
message:
'S-Gizi belum berhasil menghitung data. Periksa koneksi atau server API, lalu coba lagi.',
onRetry: _retry,
);
}
if (snapshot.hasData) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final childId = widget.payload['child_id'];
final tanggalUkur = widget.payload['tanggal_ukur'];
if (childId is int && tanggalUkur is String) {
SgiziAppState.instance.updateChildMeasurementSnapshot(
childId: childId,
latestStatus: snapshot.data!.statusGabungan,
latestMeasurementAt: tanggalUkur,
);
}
if (!mounted) return;
Navigator.of(context).pushReplacement(
fadeRoute(ResultScreen(result: snapshot.data!)),
);
});
}
return const _LoadingContent();
},
),
);
}
}
class _LoadingContent extends StatefulWidget {
const _LoadingContent();
@override
State<_LoadingContent> createState() => _LoadingContentState();
}
class _LoadingContentState extends State<_LoadingContent>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RotationTransition(
turns: Tween<double>(begin: 0, end: 1).animate(_controller),
child: Container(
width: 112,
height: 112,
decoration: BoxDecoration(
color: const Color(0xFFEFF8F7),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFE2F1EF),
width: 8,
),
),
child: const Icon(
Icons.auto_awesome_rounded,
color: SgColors.primary,
size: 38,
),
),
),
const SizedBox(height: 32),
const Text(
'Sedang menghitung status gizi...',
style: AppTypography.h1,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'Mohon tunggu sebentar, sistem S-Gizi sedang menganalisis data pertumbuhan si Kecil berdasarkan standar kesehatan.',
style: AppTypography.body,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final value = 0.18 + (_controller.value * 0.72);
return LinearProgressIndicator(
value: value,
minHeight: 8,
backgroundColor: const Color(0xFFEFF4F2),
valueColor: const AlwaysStoppedAnimation(
SgColors.primary,
),
);
},
),
),
const SizedBox(height: 12),
AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final percent = (20 + (_controller.value * 70)).round();
return Text(
'MEMPROSES DATA $percent%',
style: AppTypography.caption.copyWith(
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
),
);
},
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/child_chat_detail_model.dart';
import 'package:s_gizi/providers/consultation_provider.dart';
import 'package:s_gizi/widgets/history_measurement_card.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/measurement_info_row.dart';
import 'package:s_gizi/widgets/risk_badge.dart';
import 'package:s_gizi/widgets/zscore_card.dart';
class ChildDetailFromChatScreen extends StatefulWidget {
const ChildDetailFromChatScreen({
super.key,
required this.consultationId,
this.initialData,
});
final int consultationId;
final ChildChatDetailModel? initialData;
@override
State<ChildDetailFromChatScreen> createState() =>
_ChildDetailFromChatScreenState();
}
class _ChildDetailFromChatScreenState extends State<ChildDetailFromChatScreen> {
final _note = TextEditingController();
String _category = 'Saran pola makan';
late final ConsultationProvider _provider;
@override
void initState() {
super.initState();
_provider = ConsultationProvider();
_provider.childDetail = widget.initialData;
_provider.fetchMessages(widget.consultationId);
}
@override
void dispose() {
_note.dispose();
_provider.dispose();
super.dispose();
}
Future<void> _saveNote() async {
final ok = await _provider.saveNote(
consultationId: widget.consultationId,
category: _category,
note: _note.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok ? 'Catatan disimpan.' : 'Gagal menyimpan catatan.'),
),
);
if (ok) _note.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: AppBar(title: const Text('Detail Anak')),
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
final child = _provider.childDetail;
if (_provider.isLoading && child == null) {
return const LoadingSkeleton();
}
if (_provider.errorMessage != null && child == null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: () => _provider.fetchMessages(widget.consultationId),
);
}
if (child == null) {
return const EmptyState(
title: 'Data anak belum tersedia',
message: 'Detail anak hanya tampil dari konsultasi yang masuk.',
icon: LucideIcons.baby,
);
}
final z = child.zscoreResult;
final m = child.latestMeasurement;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 120),
children: [
HealthCard(
dense: true,
child: Row(
children: [
SgAvatar(
name: child.name,
radius: 30,
icon: LucideIcons.baby,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(child.name, style: AppTypography.h2),
Text(
'${child.ageText}${child.gender}',
style: AppTypography.caption,
),
Text(
'Orang tua: ${child.parentName}',
style: AppTypography.caption,
),
],
),
),
RiskBadge(status: child.riskStatus),
],
),
),
const SizedBox(height: 12),
HealthCard(
dense: true,
child: Column(
children: [
MeasurementInfoRow(
label: 'Tanggal',
value: m.measurementDate,
icon: LucideIcons.calendar,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Umur saat ukur',
value: m.ageAtMeasurement,
icon: LucideIcons.clock,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Berat badan',
value: '${m.weightKg.toStringAsFixed(1)} kg',
icon: LucideIcons.scale,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Tinggi badan',
value: '${m.heightCm.toStringAsFixed(0)} cm',
icon: LucideIcons.ruler,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Posisi ukur',
value: m.position,
icon: LucideIcons.accessibility,
),
],
),
),
const SizedBox(height: 16),
Text('Hasil Z-score WHO', style: AppTypography.h2),
const SizedBox(height: 10),
ZScoreCard(
title: 'BB/U',
score: z.bbuScore,
status: z.bbuStatus,
),
const SizedBox(height: 10),
ZScoreCard(
title: 'TB/U',
score: z.tbuScore,
status: z.tbuStatus,
),
const SizedBox(height: 10),
ZScoreCard(
title: 'BB/TB',
score: z.bbtbScore,
status: z.bbtbStatus,
),
const SizedBox(height: 12),
HealthCard(
dense: true,
color: const Color(0xFFFFF8E8),
child: Text(child.interpretation, style: AppTypography.body),
),
const SizedBox(height: 16),
Text('Riwayat Pengukuran Singkat', style: AppTypography.h2),
const SizedBox(height: 10),
...child.shortHistories
.take(3)
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: HistoryMeasurementCard(history: item),
),
),
const SizedBox(height: 16),
Text('Catatan Ahli Gizi', style: AppTypography.h2),
const SizedBox(height: 10),
...child.notes.map(
(note) => HealthCard(
dense: true,
margin: const EdgeInsets.only(bottom: 10),
child: Text(note.note, style: AppTypography.body),
),
),
DropdownButtonFormField<String>(
initialValue: _category,
items:
const [
'Saran pola makan',
'Saran pengukuran ulang',
'Saran konsultasi lanjutan',
'Catatan umum',
]
.map(
(e) => DropdownMenuItem(value: e, child: Text(e)),
)
.toList(),
onChanged: (value) =>
setState(() => _category = value ?? _category),
),
const SizedBox(height: 10),
TextField(
controller: _note,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
hintText: 'Tulis catatan...',
),
),
const SizedBox(height: 12),
PrimaryButton(label: 'Simpan Catatan', onPressed: _saveNote),
],
);
},
),
),
);
}
}

View File

@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/child_detail_model.dart';
import 'package:s_gizi/providers/child_detail_provider.dart';
import 'package:s_gizi/screens/nutritionist/consultation_chat_screen.dart';
import 'package:s_gizi/screens/nutritionist/nutritionist_note_screen.dart';
import 'package:s_gizi/screens/nutritionist/quick_validation_screen.dart';
import 'package:s_gizi/widgets/action_button_card.dart';
import 'package:s_gizi/widgets/history_measurement_card.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/measurement_info_row.dart';
import 'package:s_gizi/widgets/risk_badge.dart';
import 'package:s_gizi/widgets/zscore_card.dart';
class ChildDetailScreen extends StatefulWidget {
const ChildDetailScreen({super.key, required this.childId});
final int childId;
@override
State<ChildDetailScreen> createState() => _ChildDetailScreenState();
}
class _ChildDetailScreenState extends State<ChildDetailScreen> {
late final ChildDetailProvider _provider;
@override
void initState() {
super.initState();
_provider = ChildDetailProvider()..fetchChildDetail(widget.childId);
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: AppBar(title: const Text('Detail Anak')),
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: () => _provider.fetchChildDetail(widget.childId),
);
}
final child = _provider.childDetail;
if (child == null) {
return const EmptyState(
title: 'Detail anak tidak tersedia',
message: 'Silakan coba lagi beberapa saat.',
);
}
return RefreshIndicator(
color: SgColors.primary,
onRefresh: () => _provider.fetchChildDetail(widget.childId),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 120),
physics: const AlwaysScrollableScrollPhysics(),
children: [
_ProfileCard(child: child),
const SizedBox(height: 12),
_MeasurementCard(measurement: child.latestMeasurement),
const SizedBox(height: 16),
Text('Hasil Z-score WHO', style: AppTypography.h2),
const SizedBox(height: 10),
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 380;
return GridView.count(
crossAxisCount: compact ? 1 : 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: compact ? 2.35 : 0.95,
children: [
ZScoreCard(
title: 'BB/U',
score: child.zscoreResult.bbuScore,
status: child.zscoreResult.bbuStatus,
),
ZScoreCard(
title: 'TB/U',
score: child.zscoreResult.tbuScore,
status: child.zscoreResult.tbuStatus,
),
ZScoreCard(
title: 'BB/TB',
score: child.zscoreResult.bbtbScore,
status: child.zscoreResult.bbtbStatus,
),
],
);
},
),
const SizedBox(height: 14),
_InterpretationCard(text: child.interpretation),
const SizedBox(height: 16),
Text('Riwayat Pengukuran Singkat', style: AppTypography.h2),
const SizedBox(height: 10),
...child.shortHistories
.take(5)
.map(
(history) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: HistoryMeasurementCard(history: history),
),
),
const SizedBox(height: 10),
_ActionArea(child: child),
],
),
);
},
),
),
);
}
}
class _ProfileCard extends StatelessWidget {
const _ProfileCard({required this.child});
final ChildDetailModel child;
@override
Widget build(BuildContext context) {
return HealthCard(
dense: true,
child: Row(
children: [
SgAvatar(name: child.name, radius: 30, icon: LucideIcons.baby),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(child.name, style: AppTypography.h2),
const SizedBox(height: 3),
Text(
'${child.ageText}${child.gender}',
style: AppTypography.caption,
),
Text(
'Orang tua: ${child.parentName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
Text(
child.parentPhone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
],
),
),
const SizedBox(width: 8),
RiskBadge(status: child.riskStatus),
],
),
);
}
}
class _MeasurementCard extends StatelessWidget {
const _MeasurementCard({required this.measurement});
final LatestMeasurementModel measurement;
@override
Widget build(BuildContext context) {
return HealthCard(
dense: true,
child: Column(
children: [
MeasurementInfoRow(
label: 'Tanggal',
value: measurement.measurementDate,
icon: LucideIcons.calendar,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Umur saat ukur',
value: measurement.ageAtMeasurement,
icon: LucideIcons.clock,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Berat badan',
value: '${measurement.weightKg.toStringAsFixed(1)} kg',
icon: LucideIcons.scale,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Tinggi badan',
value: '${measurement.heightCm.toStringAsFixed(0)} cm',
icon: LucideIcons.ruler,
),
const SizedBox(height: 8),
MeasurementInfoRow(
label: 'Posisi ukur',
value: measurement.position,
icon: LucideIcons.accessibility,
),
],
),
);
}
}
class _InterpretationCard extends StatelessWidget {
const _InterpretationCard({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return HealthCard(
dense: true,
color: const Color(0xFFFFF8E8),
borderColor: const Color(0xFFFFE2A8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFFEF6C00)),
const SizedBox(width: 10),
Expanded(child: Text(text, style: AppTypography.body)),
],
),
);
}
}
class _ActionArea extends StatelessWidget {
const _ActionArea({required this.child});
final ChildDetailModel child;
@override
Widget build(BuildContext context) {
final canChat = child.hasConsultation && child.consultationId != null;
return Column(
children: [
ActionButtonCard(
label: canChat ? 'Chat Orang Tua' : 'Chat Belum Tersedia',
icon: LucideIcons.messageCircle,
enabled: canChat,
onTap: () {
if (!canChat) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Chat belum tersedia. Orang tua belum memulai konsultasi.',
),
),
);
return;
}
Navigator.of(context).push(
fadeRoute(
NutritionistConsultationChatScreen(
consultationId: child.consultationId!,
title: child.parentName,
),
),
);
},
),
if (!canChat) ...[
const SizedBox(height: 6),
Text(
'Chat belum tersedia. Orang tua belum memulai konsultasi.',
style: AppTypography.caption,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 10),
ActionButtonCard(
label: 'Tambah Catatan',
icon: LucideIcons.fileEdit,
onTap: () => Navigator.of(
context,
).push(fadeRoute(NutritionistNoteScreen(childId: child.id))),
),
const SizedBox(height: 10),
ActionButtonCard(
label: 'Validasi Data',
icon: LucideIcons.badgeCheck,
onTap: () => Navigator.of(context).push(
fadeRoute(
QuickValidationScreen(
measurementId: child.latestMeasurement.measurementId,
),
),
),
),
],
);
}
}

View File

@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/child_monitoring_provider.dart';
import 'package:s_gizi/screens/nutritionist/child_detail_screen.dart';
import 'package:s_gizi/widgets/child_monitoring_card.dart';
import 'package:s_gizi/widgets/filter_chip_status.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/nutritionist_bottom_nav_bar.dart';
import 'package:s_gizi/widgets/search_bar_widget.dart';
import 'package:s_gizi/widgets/summary_count_card.dart';
class ChildMonitoringScreen extends StatefulWidget {
const ChildMonitoringScreen({
super.key,
this.showAppBar = true,
this.showBottomNav = true,
});
final bool showAppBar;
final bool showBottomNav;
@override
State<ChildMonitoringScreen> createState() => _ChildMonitoringScreenState();
}
class _ChildMonitoringScreenState extends State<ChildMonitoringScreen> {
late final ChildMonitoringProvider _provider;
@override
void initState() {
super.initState();
_provider = ChildMonitoringProvider()..fetchChildren();
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: widget.showAppBar ? AppBar(title: const Text('Data Anak')) : null,
bottomNavigationBar: widget.showBottomNav
? NutritionistBottomNavBar(
currentIndex: 1,
onTap: (index) {
if (index == 1) return;
Navigator.of(context).maybePop();
},
)
: null,
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: _provider.fetchChildren,
);
}
return RefreshIndicator(
color: SgColors.primary,
onRefresh: _provider.refreshChildren,
child: ListView(
padding: EdgeInsets.fromLTRB(
20,
widget.showAppBar ? 14 : 4,
20,
widget.showBottomNav ? 120 : 28,
),
physics: const AlwaysScrollableScrollPhysics(),
children: [
SearchBarWidget(
hintText: 'Cari nama anak atau orang tua',
onChanged: _provider.setSearchQuery,
),
const SizedBox(height: 12),
_FilterRow(provider: _provider),
const SizedBox(height: 12),
_SummaryRow(provider: _provider),
const SizedBox(height: 16),
if (_provider.children.isEmpty)
const EmptyState(
title: 'Belum ada data anak',
message:
'Data akan muncul setelah orang tua menambahkan data anak dan melakukan pengukuran.',
icon: LucideIcons.baby,
)
else
..._provider.children.map(
(child) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ChildMonitoringCard(
child: child,
onTap: () => Navigator.of(context).push(
fadeRoute(ChildDetailScreen(childId: child.id)),
),
),
),
),
],
),
);
},
),
),
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({required this.provider});
final ChildMonitoringProvider provider;
@override
Widget build(BuildContext context) {
const filters = [
('Semua', 'all'),
('Risiko Tinggi', 'high_risk'),
('Perlu Dipantau', 'watch'),
('Perlu Ukur Ulang', 'anomaly'),
('Normal', 'normal'),
];
return SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final item = filters[index];
return FilterChipStatus(
label: item.$1,
value: item.$2,
selectedValue: provider.selectedFilter,
onSelected: provider.setFilter,
);
},
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.provider});
final ChildMonitoringProvider provider;
@override
Widget build(BuildContext context) {
final summary = provider.summary;
final items = [
('Total', summary.total, LucideIcons.users, SgColors.primary),
(
'Risiko',
summary.highRisk,
Icons.warning_amber_rounded,
const Color(0xFFC62828),
),
(
'Ukur Ulang',
summary.anomaly,
LucideIcons.activity,
const Color(0xFFEF6C00),
),
(
'Normal',
summary.normal,
LucideIcons.checkCircle,
const Color(0xFF2E7D32),
),
];
return SizedBox(
height: 72,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final item = items[index];
return SummaryCountCard(
label: item.$1,
value: item.$2,
icon: item.$3,
color: item.$4,
);
},
),
);
}
}

View File

@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/consultation_model.dart';
import 'package:s_gizi/providers/consultation_provider.dart';
import 'package:s_gizi/screens/nutritionist/child_detail_from_chat_screen.dart';
import 'package:s_gizi/widgets/chat_bubble.dart';
import 'package:s_gizi/widgets/child_summary_card.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
class ConsultationChatScreen extends StatefulWidget {
const ConsultationChatScreen({super.key, required this.consultation});
final ConsultationModel consultation;
@override
State<ConsultationChatScreen> createState() => _ConsultationChatScreenState();
}
class _ConsultationChatScreenState extends State<ConsultationChatScreen> {
final _input = TextEditingController();
late final ConsultationProvider _provider;
@override
void initState() {
super.initState();
_provider = ConsultationProvider()..fetchMessages(widget.consultation.id);
}
@override
void dispose() {
_input.dispose();
_provider.dispose();
super.dispose();
}
Future<void> _send() async {
final ok = await _provider.sendMessage(widget.consultation.id, _input.text);
if (!mounted) return;
if (ok) {
_input.clear();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_provider.errorMessage ?? 'Gagal mengirim pesan.'),
),
);
}
}
@override
Widget build(BuildContext context) {
final closed = widget.consultation.isClosed;
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.consultation.parentName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'Anak: ${widget.consultation.childName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
color: SgColors.textSecondary,
fontSize: 12,
),
),
],
),
actions: [
TextButton(
onPressed: closed
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await _provider.closeConsultation(
widget.consultation.id,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? 'Konsultasi ditandai selesai.'
: 'Gagal menutup konsultasi.',
),
),
);
},
child: const Text('Selesai'),
),
],
),
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: () => _provider.fetchMessages(widget.consultation.id),
);
}
return Column(
children: [
if (_provider.childDetail != null)
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 6),
child: ChildSummaryCard(
child: _provider.childDetail!,
onDetail: () => Navigator.of(context).push(
fadeRoute(
ChildDetailFromChatScreen(
consultationId: widget.consultation.id,
initialData: _provider.childDetail,
),
),
),
),
),
Expanded(
child: _provider.messages.isEmpty
? const EmptyState(
title: 'Belum ada pesan',
message:
'Chat akan tampil setelah orang tua mengirim pesan.',
icon: LucideIcons.messageCircle,
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
itemCount: _provider.messages.length,
itemBuilder: (context, index) =>
ChatBubble(message: _provider.messages[index]),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _input,
enabled: !closed,
minLines: 1,
maxLines: 4,
decoration: InputDecoration(
hintText: closed
? 'Konsultasi sudah selesai'
: 'Tulis balasan...',
),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: closed || _provider.isSending ? null : _send,
icon: const Icon(LucideIcons.send),
),
],
),
),
],
);
},
),
),
);
}
}

View File

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/consultation_provider.dart';
import 'package:s_gizi/screens/nutritionist/consultation_chat_screen.dart';
import 'package:s_gizi/widgets/consultation_card.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/nutritionist_bottom_nav_bar.dart';
import 'package:s_gizi/widgets/search_bar_widget.dart';
class ConsultationListScreen extends StatefulWidget {
const ConsultationListScreen({super.key, this.showBottomNav = true});
final bool showBottomNav;
@override
State<ConsultationListScreen> createState() => _ConsultationListScreenState();
}
class _ConsultationListScreenState extends State<ConsultationListScreen> {
late final ConsultationProvider _provider;
@override
void initState() {
super.initState();
_provider = ConsultationProvider()..fetchConsultations();
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: widget.showBottomNav
? AppBar(title: const Text('Konsultasi'))
: null,
bottomNavigationBar: widget.showBottomNav
? NutritionistBottomNavBar(
currentIndex: 2,
onTap: (_) => Navigator.of(context).maybePop(),
)
: null,
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: _provider.fetchConsultations,
);
}
return RefreshIndicator(
color: SgColors.primary,
onRefresh: _provider.fetchConsultations,
child: ListView(
padding: EdgeInsets.fromLTRB(
20,
widget.showBottomNav ? 14 : 8,
20,
120,
),
physics: const AlwaysScrollableScrollPhysics(),
children: [
if (!widget.showBottomNav) ...[
Text(
'Konsultasi',
style: AppTypography.h1.copyWith(fontSize: 28),
),
const SizedBox(height: 10),
],
SearchBarWidget(
hintText: 'Cari nama orang tua atau anak',
onChanged: _provider.setSearchQuery,
),
const SizedBox(height: 12),
_FilterRow(provider: _provider),
const SizedBox(height: 14),
if (_provider.consultations.isEmpty)
const EmptyState(
title: 'Belum ada konsultasi masuk',
message:
'Konsultasi akan muncul jika orang tua mengirim pesan.',
)
else
..._provider.consultations.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ConsultationCard(
consultation: item,
onTap: () => Navigator.of(context).push(
fadeRoute(
ConsultationChatScreen(consultation: item),
),
),
),
),
),
],
),
);
},
),
),
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({required this.provider});
final ConsultationProvider provider;
@override
Widget build(BuildContext context) {
const filters = [
('Semua', 'all'),
('Belum Dibalas', 'unreplied'),
('Aktif', 'active'),
('Risiko Tinggi', 'high_risk'),
('Selesai', 'closed'),
];
return SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final item = filters[index];
final active = provider.selectedFilter == item.$2;
return ChoiceChip(
selected: active,
showCheckmark: false,
label: Text(item.$1),
onSelected: (_) => provider.setFilter(item.$2),
selectedColor: SgColors.primary,
labelStyle: AppTypography.caption.copyWith(
color: active ? Colors.white : SgColors.textPrimary,
fontWeight: FontWeight.w800,
),
);
},
),
);
}
}

View File

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/notification_provider.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/notification_card.dart';
import 'package:s_gizi/widgets/nutritionist_bottom_nav_bar.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key, this.showBottomNav = true});
final bool showBottomNav;
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
late final NotificationProvider _provider;
@override
void initState() {
super.initState();
_provider = NotificationProvider()..fetchNotifications();
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: widget.showBottomNav
? AppBar(title: const Text('Notifikasi'))
: null,
bottomNavigationBar: widget.showBottomNav
? NutritionistBottomNavBar(
currentIndex: 3,
onTap: (_) => Navigator.of(context).maybePop(),
)
: null,
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: _provider.fetchNotifications,
);
}
return RefreshIndicator(
color: SgColors.primary,
onRefresh: _provider.fetchNotifications,
child: ListView(
padding: EdgeInsets.fromLTRB(
20,
widget.showBottomNav ? 14 : 8,
20,
120,
),
physics: const AlwaysScrollableScrollPhysics(),
children: [
if (!widget.showBottomNav) ...[
Text(
'Notifikasi',
style: AppTypography.h1.copyWith(fontSize: 28),
),
const SizedBox(height: 10),
],
_FilterRow(provider: _provider),
const SizedBox(height: 14),
if (_provider.notifications.isEmpty)
const EmptyState(
title: 'Belum ada notifikasi baru',
message: 'Notifikasi penting akan muncul di sini.',
)
else
..._provider.notifications.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: NotificationCard(
notification: item,
onTap: () => _provider.markRead(item.id),
),
),
),
],
),
);
},
),
),
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({required this.provider});
final NotificationProvider provider;
@override
Widget build(BuildContext context) {
const filters = [
('Semua', 'all'),
('Belum Dibaca', 'unread'),
('Risiko Tinggi', 'high_risk'),
('Pesan Baru', 'message'),
];
return SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final item = filters[index];
final active = provider.selectedFilter == item.$2;
return ChoiceChip(
selected: active,
showCheckmark: false,
label: Text(item.$1),
onSelected: (_) => provider.setFilter(item.$2),
selectedColor: SgColors.primary,
labelStyle: AppTypography.caption.copyWith(
color: active ? Colors.white : SgColors.textPrimary,
fontWeight: FontWeight.w800,
),
);
},
),
);
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/nutritionist_dashboard_provider.dart';
import 'package:s_gizi/screens/nutritionist/consultation_chat_screen.dart';
import 'package:s_gizi/screens/nutritionist/consultation_list_screen.dart';
import 'package:s_gizi/screens/nutritionist/notification_screen.dart';
import 'package:s_gizi/screens/nutritionist/nutritionist_profile_screen.dart';
import 'package:s_gizi/widgets/consultation_card.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/notification_card.dart';
import 'package:s_gizi/widgets/nutritionist_bottom_nav_bar.dart';
import 'package:s_gizi/widgets/summary_card.dart';
class NutritionistDashboardScreen extends StatefulWidget {
const NutritionistDashboardScreen({super.key});
@override
State<NutritionistDashboardScreen> createState() =>
_NutritionistDashboardScreenState();
}
class _NutritionistDashboardScreenState
extends State<NutritionistDashboardScreen> {
int _index = 0;
late final _pages = [
_NutritionistDashboardHome(
onChangeTab: (value) => setState(() => _index = value),
),
const ConsultationListScreen(showBottomNav: false),
const NotificationScreen(showBottomNav: false),
const NutritionistProfileScreen(showBottomNav: false),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
body: IndexedStack(index: _index, children: _pages),
bottomNavigationBar: NutritionistBottomNavBar(
currentIndex: _index,
onTap: (value) => setState(() => _index = value),
),
);
}
}
class _NutritionistDashboardHome extends StatefulWidget {
const _NutritionistDashboardHome({required this.onChangeTab});
final ValueChanged<int> onChangeTab;
@override
State<_NutritionistDashboardHome> createState() =>
_NutritionistDashboardHomeState();
}
class _NutritionistDashboardHomeState
extends State<_NutritionistDashboardHome> {
late final NutritionistDashboardProvider _provider;
@override
void initState() {
super.initState();
_provider = NutritionistDashboardProvider()..fetchDashboard();
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
if (_provider.isLoading) return const LoadingSkeleton();
if (_provider.errorMessage != null) {
return ErrorState(
message: _provider.errorMessage!,
onRetry: _provider.fetchDashboard,
);
}
final data = _provider.dashboardData;
if (data == null) {
return const EmptyState(
title: 'Belum ada konsultasi hari ini',
message: 'Ringkasan konsultasi akan tampil di sini.',
);
}
final profile = data.nutritionist;
final hour = DateTime.now().hour;
final greeting = hour < 11
? 'Selamat pagi'
: hour < 15
? 'Selamat siang'
: hour < 18
? 'Selamat sore'
: 'Selamat malam';
return RefreshIndicator(
color: SgColors.primary,
onRefresh: _provider.refreshDashboard,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 120),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
children: [
Text(
'S-Gizi',
style: AppTypography.h2.copyWith(fontSize: 21),
),
const Spacer(),
IconButton(
onPressed: () => widget.onChangeTab(2),
icon: const Icon(LucideIcons.bell),
),
SgAvatar(name: profile.name, radius: 21),
],
),
const SizedBox(height: 14),
Text(
'$greeting, ${profile.name}',
style: AppTypography.h1.copyWith(fontSize: 25),
),
const SizedBox(height: 4),
const Text(
'Pantau konsultasi gizi hari ini.',
style: AppTypography.body,
),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.45,
children: [
SummaryCard(
title: 'Konsultasi Aktif',
value: data.summary.activeConsultations,
icon: LucideIcons.messageCircle,
color: SgColors.primary,
onTap: () => widget.onChangeTab(1),
),
SummaryCard(
title: 'Belum Dibalas',
value: data.summary.unrepliedMessages,
icon: LucideIcons.reply,
color: const Color(0xFF3B82F6),
onTap: () => widget.onChangeTab(1),
),
SummaryCard(
title: 'Risiko Tinggi',
value: data.summary.highRiskConsultations,
icon: Icons.warning_amber_rounded,
color: const Color(0xFFC62828),
onTap: () => widget.onChangeTab(2),
),
SummaryCard(
title: 'Data Perlu Dicek',
value: data.summary.needReviewData,
icon: LucideIcons.activity,
color: const Color(0xFFEF6C00),
onTap: () => widget.onChangeTab(2),
),
],
),
const SizedBox(height: 18),
_SectionTitle(
title: 'Konsultasi Terbaru',
onTap: () => widget.onChangeTab(1),
),
const SizedBox(height: 10),
if (data.latestConsultations.isEmpty)
const EmptyState(
title: 'Belum ada konsultasi hari ini',
message:
'Konsultasi akan muncul jika orang tua mengirim pesan.',
icon: LucideIcons.messageCircle,
)
else
...data.latestConsultations
.take(2)
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ConsultationCard(
consultation: item,
onTap: () => Navigator.of(context).push(
fadeRoute(
ConsultationChatScreen(consultation: item),
),
),
),
),
),
const SizedBox(height: 8),
_SectionTitle(
title: 'Notifikasi Terbaru',
onTap: () => widget.onChangeTab(2),
),
const SizedBox(height: 10),
...data.latestNotifications
.take(2)
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: NotificationCard(notification: item),
),
),
],
),
);
},
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Text(title, style: AppTypography.h2)),
TextButton(onPressed: onTap, child: const Text('Lihat Semua')),
],
);
}
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/nutritionist_note_provider.dart';
class NutritionistNoteScreen extends StatefulWidget {
const NutritionistNoteScreen({super.key, required this.childId});
final int childId;
@override
State<NutritionistNoteScreen> createState() => _NutritionistNoteScreenState();
}
class _NutritionistNoteScreenState extends State<NutritionistNoteScreen> {
final _controller = TextEditingController();
late final NutritionistNoteProvider _provider;
@override
void initState() {
super.initState();
_provider = NutritionistNoteProvider();
}
@override
void dispose() {
_controller.dispose();
_provider.dispose();
super.dispose();
}
Future<void> _save() async {
FocusScope.of(context).unfocus();
final ok = await _provider.saveNote(
childId: widget.childId,
note: _controller.text,
);
if (!mounted) return;
if (ok) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Catatan berhasil disimpan.')),
);
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_provider.errorMessage ?? 'Gagal menyimpan catatan.'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: AppBar(title: const Text('Catatan Ahli Gizi')),
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 120),
children: [
const Text(
'Tambahkan catatan singkat untuk memudahkan monitoring berikutnya.',
style: AppTypography.body,
),
const SizedBox(height: 14),
TextField(
controller: _controller,
minLines: 6,
maxLines: 10,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: 'Tulis catatan ahli gizi...',
),
),
const SizedBox(height: 16),
PrimaryButton(
label: _provider.isSaving ? 'Menyimpan...' : 'Simpan Catatan',
onPressed: _provider.isSaving ? null : _save,
),
],
);
},
),
),
);
}
}

View File

@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/features/auth/screens/auth_screen.dart';
import 'package:s_gizi/providers/auth_provider.dart';
import 'package:s_gizi/providers/profile_provider.dart';
import 'package:s_gizi/widgets/loading_skeleton.dart';
import 'package:s_gizi/widgets/nutritionist_bottom_nav_bar.dart';
import 'package:s_gizi/widgets/profile_info_tile.dart';
class NutritionistProfileScreen extends StatefulWidget {
const NutritionistProfileScreen({super.key, this.showBottomNav = true});
final bool showBottomNav;
@override
State<NutritionistProfileScreen> createState() =>
_NutritionistProfileScreenState();
}
class _NutritionistProfileScreenState extends State<NutritionistProfileScreen> {
late final ProfileProvider _profile;
late final AuthProvider _auth;
@override
void initState() {
super.initState();
_profile = ProfileProvider()..fetchProfile();
_auth = AuthProvider();
}
@override
void dispose() {
_profile.dispose();
_auth.dispose();
super.dispose();
}
Future<void> _logout() async {
await _auth.logout();
if (!mounted) return;
Navigator.of(
context,
).pushAndRemoveUntil(fadeRoute(const AuthScreen()), (_) => false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: widget.showBottomNav ? AppBar(title: const Text('Profil')) : null,
bottomNavigationBar: widget.showBottomNav
? NutritionistBottomNavBar(
currentIndex: 3,
onTap: (_) => Navigator.of(context).maybePop(),
)
: null,
body: SafeArea(
child: AnimatedBuilder(
animation: _profile,
builder: (context, _) {
if (_profile.isLoading) return const LoadingSkeleton();
if (_profile.errorMessage != null) {
return ErrorState(
message: _profile.errorMessage!,
onRetry: _profile.fetchProfile,
);
}
final profile = _profile.profile;
if (profile == null) {
return const EmptyState(
title: 'Profil belum tersedia',
message: 'Silakan coba lagi.',
);
}
return ListView(
padding: EdgeInsets.fromLTRB(
20,
widget.showBottomNav ? 14 : 8,
20,
120,
),
children: [
if (!widget.showBottomNav) ...[
Text(
'Profil',
style: AppTypography.h1.copyWith(fontSize: 28),
),
const SizedBox(height: 10),
],
HealthCard(
dense: true,
child: Row(
children: [
SgAvatar(name: profile.name, radius: 34),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(profile.name, style: AppTypography.h2),
Text(profile.profession, style: AppTypography.body),
const SizedBox(height: 6),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('Aktif menerima konsultasi'),
value: profile.isActive,
onChanged: _profile.updateStatus,
),
],
),
),
],
),
),
const SizedBox(height: 12),
ProfileInfoTile(
icon: LucideIcons.phone,
label: 'Nomor HP',
value: profile.phone,
),
const SizedBox(height: 10),
ProfileInfoTile(
icon: LucideIcons.mail,
label: 'Email',
value: profile.email,
),
const SizedBox(height: 10),
ProfileInfoTile(
icon: LucideIcons.building2,
label: 'Tempat Kerja',
value: profile.workplace,
),
const SizedBox(height: 16),
PrimaryButton(
label: _auth.isLoading ? 'Keluar...' : 'Logout',
icon: LucideIcons.logOut,
isOutlined: true,
onPressed: _auth.isLoading ? null : _logout,
),
],
);
},
),
),
);
}
}

View File

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/providers/quick_validation_provider.dart';
class QuickValidationScreen extends StatefulWidget {
const QuickValidationScreen({super.key, required this.measurementId});
final int measurementId;
@override
State<QuickValidationScreen> createState() => _QuickValidationScreenState();
}
class _QuickValidationScreenState extends State<QuickValidationScreen> {
final _note = TextEditingController();
late final QuickValidationProvider _provider;
@override
void initState() {
super.initState();
_provider = QuickValidationProvider();
}
@override
void dispose() {
_note.dispose();
_provider.dispose();
super.dispose();
}
Future<void> _submit(bool accepted) async {
final ok = await _provider.validate(
measurementId: widget.measurementId,
accepted: accepted,
note: _note.text,
);
if (!mounted) return;
if (ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
accepted
? 'Data ditandai valid.'
: 'Data ditandai perlu verifikasi.',
),
),
);
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_provider.errorMessage ?? 'Validasi gagal.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7FAFA),
appBar: AppBar(title: const Text('Validasi Data')),
body: SafeArea(
child: AnimatedBuilder(
animation: _provider,
builder: (context, _) {
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 120),
children: [
HealthCard(
dense: true,
color: const Color(0xFFFFFBEB),
borderColor: const Color(0xFFFFE2A8),
child: Text(
'Validasi cepat hanya menandai data untuk monitoring. Pemeriksaan lengkap tetap dilakukan di website ahli gizi.',
style: AppTypography.body,
),
),
const SizedBox(height: 14),
TextField(
controller: _note,
minLines: 4,
maxLines: 8,
decoration: const InputDecoration(
hintText: 'Catatan validasi opsional...',
),
),
const SizedBox(height: 16),
PrimaryButton(
label: _provider.isSaving ? 'Memproses...' : 'Data Valid',
onPressed: _provider.isSaving ? null : () => _submit(true),
),
const SizedBox(height: 10),
PrimaryButton(
label: 'Perlu Verifikasi',
isOutlined: true,
onPressed: _provider.isSaving ? null : () => _submit(false),
),
],
);
},
),
),
);
}
}

View File

@ -6,12 +6,12 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import '../app_design.dart'; import '../app_design.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../models/mobile_child_model.dart'; import '../models/mobile_child_model.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
import '../utils/nutrition_display_utils.dart'; import '../utils/nutrition_display_utils.dart';
import '../widgets/nutrition_status_badges.dart';
import 'add_child_screen.dart'; import 'add_child_screen.dart';
import 'account_info_screen.dart'; import 'account_info_screen.dart';
import 'auth_screen.dart'; import 'auth_screen.dart';
@ -51,10 +51,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
Future<void> _load() async { Future<void> _load() async {
if (!_state.isAuthenticated) return; if (!_state.isAuthenticated) return;
final results = await Future.wait([ final results = await Future.wait([_api.getChildren(), _api.getProfile()]);
_api.getChildren(),
_api.getProfile(),
]);
final children = results[0] as List<MobileChildModel>; final children = results[0] as List<MobileChildModel>;
final profile = results[1] as Map<String, dynamic>; final profile = results[1] as Map<String, dynamic>;
_state.setChildren(children); _state.setChildren(children);
@ -83,7 +80,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
final role = (profile?['role'] as String? ?? 'orang_tua').trim(); final role = (profile?['role'] as String? ?? 'orang_tua').trim();
return SingleChildScrollView( return SingleChildScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 28), padding: const EdgeInsets.fromLTRB(
SgSpacing.pageH,
SgSpacing.pageV,
SgSpacing.pageH,
20,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -91,9 +93,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
name: name.isEmpty ? 'Siti Aminah' : name, name: name.isEmpty ? 'Siti Aminah' : name,
subtitle: _profileRoleToLabel(role, phone), subtitle: _profileRoleToLabel(role, phone),
onEditTap: () async { onEditTap: () async {
await Navigator.of(context).push( await Navigator.of(
fadeRoute(const AccountInfoScreen()), context,
); ).push(fadeRoute(const AccountInfoScreen()));
if (!mounted) return; if (!mounted) return;
_retry(); _retry();
}, },
@ -102,38 +104,40 @@ class _ProfileScreenState extends State<ProfileScreen> {
_ChildrenSection( _ChildrenSection(
children: _state.children, children: _state.children,
activeChildId: _state.activeChildId, activeChildId: _state.activeChildId,
onAdd: () => Navigator.of(context).push( onAdd: () => Navigator.of(
fadeRoute(const AddChildScreen()), context,
), ).push(fadeRoute(const AddChildScreen())),
onSelect: (id) => _state.setActiveChild(id), onSelect: (id) => _state.setActiveChild(id),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
Text( Text(
'Pengaturan', 'Pengaturan',
style: AppTypography.h2.copyWith(color: SgColors.textPrimary), style: AppTypography.h2.copyWith(
color: SgColors.textPrimary,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard( _SettingsCard(
onTapAccount: () async { onTapAccount: () async {
await Navigator.of(context).push( await Navigator.of(
fadeRoute(const AccountInfoScreen()), context,
); ).push(fadeRoute(const AccountInfoScreen()));
if (!mounted) return; if (!mounted) return;
_retry(); _retry();
}, },
onTapPrivacy: () => Navigator.of(context).push( onTapPrivacy: () => Navigator.of(
fadeRoute(const SecurityScreen()), context,
), ).push(fadeRoute(const SecurityScreen())),
onTapHelp: () => Navigator.of(context).push( onTapHelp: () => Navigator.of(
fadeRoute(const HelpScreen()), context,
), ).push(fadeRoute(const HelpScreen())),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
_LogoutCard( _LogoutCard(
onLogout: () async { onLogout: () async {
final ok = await _confirmLogout(context); final ok = await _confirmLogout(context);
if (ok != true) return; if (ok != true) return;
_state.logout(); await _state.logout();
if (!context.mounted) return; if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil( Navigator.of(context).pushAndRemoveUntil(
fadeRoute(const AuthScreen()), fadeRoute(const AuthScreen()),
@ -155,7 +159,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
) )
.animate() .animate()
.fadeIn(duration: 260.ms, curve: Curves.easeOut) .fadeIn(duration: 260.ms, curve: Curves.easeOut)
.slideY(begin: 0.02, end: 0, duration: 260.ms, curve: Curves.easeOut); .slideY(
begin: 0.02,
end: 0,
duration: 260.ms,
curve: Curves.easeOut,
);
}, },
), ),
), ),
@ -236,14 +245,10 @@ class _ProfileHeaderCard extends StatelessWidget {
color: Colors.white.withValues(alpha: 0.22), color: Colors.white.withValues(alpha: 0.22),
), ),
), ),
child: ClipOval( child: SgAvatar(
child: Padding( name: name,
padding: const EdgeInsets.all(10), radius: 37,
child: Image.asset( icon: Icons.person_rounded,
'assets/image/onboarding_consultation.png',
fit: BoxFit.cover,
),
),
), ),
) )
.animate(onPlay: (c) => c.repeat(reverse: true)) .animate(onPlay: (c) => c.repeat(reverse: true))
@ -322,10 +327,7 @@ class _ProfileHeaderCard extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
_IconPillButton( _IconPillButton(icon: LucideIcons.pencil, onTap: onEditTap),
icon: LucideIcons.pencil,
onTap: onEditTap,
),
], ],
), ),
), ),
@ -388,7 +390,9 @@ class _ChildrenSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
'Daftar Anak', 'Daftar Anak',
style: AppTypography.h2.copyWith(color: SgColors.textPrimary), style: AppTypography.h2.copyWith(
color: SgColors.textPrimary,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@ -431,12 +435,12 @@ class _ChildrenSection extends StatelessWidget {
) )
else else
SizedBox( SizedBox(
height: 152, height: 88,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
itemCount: children.length, itemCount: children.length,
separatorBuilder: (_, __) => const SizedBox(width: 12), separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final c = children[index]; final c = children[index];
final active = c.id == activeChildId; final active = c.id == activeChildId;
@ -469,30 +473,28 @@ class _ChildCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final status = (child.latestStatus ?? '').trim().isEmpty final statusRaw = (child.latestStatus ?? '').trim();
? 'Status: Belum ada'
: 'Status: ${child.latestStatus}';
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(14),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
curve: Curves.easeOut, curve: Curves.easeOut,
width: 260, width: 210,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2E8E6), color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2E8E6),
width: active ? 1.8 : 1, width: active ? 1.6 : 1,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: active ? 0.10 : 0.06), color: Colors.black.withValues(alpha: active ? 0.08 : 0.04),
blurRadius: active ? 26 : 18, blurRadius: active ? 14 : 10,
offset: const Offset(0, 12), offset: const Offset(0, 4),
), ),
], ],
), ),
@ -503,8 +505,7 @@ class _ChildCard extends StatelessWidget {
ChildAvatar( ChildAvatar(
name: child.nama, name: child.nama,
gender: child.jenisKelamin, gender: child.jenisKelamin,
photoUrl: child.photoUrl, radius: 20,
radius: 28,
), ),
if (active) if (active)
Positioned( Positioned(
@ -522,48 +523,53 @@ class _ChildCard extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
child.nama, child.nama,
style: AppTypography.h3.copyWith(color: SgColors.textPrimary), style: AppTypography.h3.copyWith(
color: SgColors.textPrimary,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
formatAgeFromBirthDate(
child.tanggalLahir,
source: 'profile_child_card',
),
style: AppTypography.caption.copyWith(fontSize: 11),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (statusRaw.isEmpty)
Text( Text(
'${child.jenisKelamin == 'P' ? 'Perempuan' : 'Laki-laki'}${formatAgeFromBirthDate(child.tanggalLahir)}', 'Belum diukur',
style: AppTypography.caption, style: AppTypography.caption.copyWith(fontSize: 10),
maxLines: 1, )
overflow: TextOverflow.ellipsis, else
), NutritionStatusBadges(
const SizedBox(height: 10), status: statusRaw,
Container( compact: true,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), spacing: 4,
decoration: BoxDecoration( runSpacing: 4,
color: active
? const Color(0xFF7FD6C2).withValues(alpha: 0.22)
: const Color(0xFFF1F5F4),
borderRadius: BorderRadius.circular(999),
),
child: Text(
status,
style: AppTypography.caption.copyWith(
color: active ? const Color(0xFF085B63) : SgColors.textSecondary,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
PopupMenuButton<String>( PopupMenuButton<String>(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 10,
color: Colors.white,
onSelected: (value) async { onSelected: (value) async {
if (value == 'edit') { if (value == 'edit') {
await _showEditChildDialog(context, child); await _showEditChildDialog(context, child);
@ -572,13 +578,47 @@ class _ChildCard extends StatelessWidget {
await _showDeleteChildDialog(context, child); await _showDeleteChildDialog(context, child);
} }
}, },
itemBuilder: (context) => const [ itemBuilder: (context) => [
PopupMenuItem(value: 'edit', child: Text('Edit')), const PopupMenuItem(
PopupMenuItem(value: 'delete', child: Text('Hapus')), value: 'edit',
child: Row(
children: [
Icon(
LucideIcons.edit3,
size: 18,
color: SgColors.primaryTeal,
),
SizedBox(width: 10),
Text('Edit'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
LucideIcons.trash2,
size: 18,
color: Color(0xFFE25555),
),
const SizedBox(width: 10),
Text(
'Hapus',
style: AppTypography.body.copyWith(
color: const Color(0xFFE25555),
fontWeight: FontWeight.w700,
),
),
],
),
),
], ],
icon: Icon( icon: Icon(
LucideIcons.moreVertical, LucideIcons.moreVertical,
color: active ? const Color(0xFF0B7A86) : SgColors.textSecondary, color: active
? const Color(0xFF0B7A86)
: SgColors.textSecondary,
), ),
), ),
], ],
@ -681,10 +721,7 @@ class _SettingsTile extends StatelessWidget {
], ],
), ),
), ),
const Icon( const Icon(LucideIcons.chevronRight, color: SgColors.textSecondary),
LucideIcons.chevronRight,
color: SgColors.textSecondary,
),
], ],
), ),
), ),
@ -734,10 +771,7 @@ class _LogoutCard extends StatelessWidget {
color: const Color(0xFFE53935).withValues(alpha: 0.12), color: const Color(0xFFE53935).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: const Icon( child: const Icon(LucideIcons.logOut, color: Color(0xFFE53935)),
LucideIcons.logOut,
color: Color(0xFFE53935),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Expanded( const Expanded(
@ -749,7 +783,9 @@ class _LogoutCard extends StatelessWidget {
), ),
TextButton( TextButton(
onPressed: onLogout, onPressed: onLogout,
style: TextButton.styleFrom(foregroundColor: const Color(0xFFE53935)), style: TextButton.styleFrom(
foregroundColor: const Color(0xFFE53935),
),
child: const Text('Keluar'), child: const Text('Keluar'),
), ),
], ],
@ -830,7 +866,7 @@ Future<bool?> _confirmLogout(BuildContext context) {
barrierLabel: 'logout', barrierLabel: 'logout',
barrierDismissible: true, barrierDismissible: true,
barrierColor: Colors.black.withValues(alpha: 0.38), barrierColor: Colors.black.withValues(alpha: 0.38),
pageBuilder: (context, _, __) { pageBuilder: (context, _, _) {
return Center( return Center(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
@ -838,7 +874,8 @@ Future<bool?> _confirmLogout(BuildContext context) {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Container( child:
Container(
width: double.infinity, width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
@ -885,17 +922,24 @@ Future<bool?> _confirmLogout(BuildContext context) {
child: SizedBox( child: SizedBox(
height: 52, height: 52,
child: OutlinedButton( child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () =>
Navigator.of(context).pop(false),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: SgColors.textPrimary, foregroundColor: SgColors.textPrimary,
side: const BorderSide(color: SgColors.border), side: const BorderSide(
color: SgColors.border,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(
16,
),
), ),
), ),
child: const Text( child: const Text(
'Batal', 'Batal',
style: TextStyle(fontWeight: FontWeight.w700), style: TextStyle(
fontWeight: FontWeight.w700,
),
), ),
), ),
), ),
@ -905,16 +949,23 @@ Future<bool?> _confirmLogout(BuildContext context) {
child: SizedBox( child: SizedBox(
height: 52, height: 52,
child: FilledButton( child: FilledButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () =>
Navigator.of(context).pop(true),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE53935), backgroundColor: const Color(
0xFFE53935,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(
16,
),
), ),
), ),
child: const Text( child: const Text(
'Keluar', 'Keluar',
style: TextStyle(fontWeight: FontWeight.w800), style: TextStyle(
fontWeight: FontWeight.w800,
),
), ),
), ),
), ),
@ -923,7 +974,10 @@ Future<bool?> _confirmLogout(BuildContext context) {
), ),
], ],
), ),
).animate().fadeIn(duration: 180.ms).scale( )
.animate()
.fadeIn(duration: 180.ms)
.scale(
begin: const Offset(0.98, 0.98), begin: const Offset(0.98, 0.98),
end: const Offset(1, 1), end: const Offset(1, 1),
duration: 180.ms, duration: 180.ms,
@ -944,12 +998,6 @@ Future<bool?> _confirmLogout(BuildContext context) {
); );
} }
void _toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
String _profileRoleToLabel(String role, String fallbackPhone) { String _profileRoleToLabel(String role, String fallbackPhone) {
final v = role.toLowerCase(); final v = role.toLowerCase();
if (v.contains('ortu') || v.contains('orang_tua') || v.contains('parent')) { if (v.contains('ortu') || v.contains('orang_tua') || v.contains('parent')) {
@ -961,7 +1009,10 @@ String _profileRoleToLabel(String role, String fallbackPhone) {
return fallbackPhone.isEmpty ? 'Akun Aktif' : fallbackPhone; return fallbackPhone.isEmpty ? 'Akun Aktif' : fallbackPhone;
} }
Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child) async { Future<void> _showEditChildDialog(
BuildContext context,
MobileChildModel child,
) async {
final api = ApiService(); final api = ApiService();
final nameController = TextEditingController(text: child.nama); final nameController = TextEditingController(text: child.nama);
String gender = child.jenisKelamin; String gender = child.jenisKelamin;
@ -984,7 +1035,7 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: gender, initialValue: gender,
items: const [ items: const [
DropdownMenuItem(value: 'L', child: Text('Laki-laki')), DropdownMenuItem(value: 'L', child: Text('Laki-laki')),
DropdownMenuItem(value: 'P', child: Text('Perempuan')), DropdownMenuItem(value: 'P', child: Text('Perempuan')),
@ -993,7 +1044,9 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
if (v == null) return; if (v == null) return;
setLocal(() => gender = v); setLocal(() => gender = v);
}, },
decoration: const InputDecoration(labelText: 'Jenis Kelamin'), decoration: const InputDecoration(
labelText: 'Jenis Kelamin',
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
OutlinedButton( OutlinedButton(
@ -1038,7 +1091,9 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
); );
final children = await api.getChildren(); final children = await api.getChildren();
SgiziAppState.instance.setChildren(children); SgiziAppState.instance.setChildren(children);
if (dialogContext.mounted) Navigator.of(dialogContext).pop(); if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) { } catch (e) {
if (!dialogContext.mounted) return; if (!dialogContext.mounted) return;
ScaffoldMessenger.of(dialogContext).showSnackBar( ScaffoldMessenger.of(dialogContext).showSnackBar(
@ -1056,7 +1111,10 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
); );
} }
Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child) async { Future<void> _showDeleteChildDialog(
BuildContext context,
MobileChildModel child,
) async {
final api = ApiService(); final api = ApiService();
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: context, context: context,
@ -1070,7 +1128,9 @@ Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFE53935)), style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE53935),
),
child: const Text('Hapus'), child: const Text('Hapus'),
), ),
], ],
@ -1084,8 +1144,8 @@ Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child
SgiziAppState.instance.setChildren(children); SgiziAppState.instance.setChildren(children);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Gagal hapus anak: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Gagal hapus anak: $e')));
} }
} }

View File

@ -1,302 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../models/api_result_model.dart';
import '../utils/nutrition_display_utils.dart';
import 'consultation_chat_screen.dart';
import 'recommendation_screen.dart';
class ResultScreen extends StatefulWidget {
const ResultScreen({super.key, required this.result});
final ApiResultModel result;
@override
State<ResultScreen> createState() => _ResultScreenState();
}
class _ResultScreenState extends State<ResultScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fade;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
)..forward();
_fade = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final result = widget.result;
final measurement = result.measurement;
final visual = nutritionStatusVisual(result.statusGabungan);
final isNormal =
result.statusGabungan.toLowerCase().contains('normal') ||
result.statusGabungan.toLowerCase().contains('baik');
return Scaffold(
backgroundColor: const Color(0xFFEFF8F7),
appBar: AppBar(title: const Text('Hasil Analisis')),
body: FadeTransition(
opacity: _fade,
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 180),
children: [
HealthCard(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
children: [
CircleAvatar(
radius: 32,
backgroundColor: visual.color.withValues(alpha: 0.14),
child: Icon(
visual.icon,
color: visual.color,
size: 38,
),
),
const SizedBox(height: 24),
Text(
'STATUS GIZI',
style: AppTypography.caption.copyWith(
letterSpacing: 4,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
result.statusGabungan,
style: AppTypography.h1.copyWith(fontSize: 32),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
StatusBadge(
text: visual.badgeLabel,
color: visual.color,
),
],
),
),
const SizedBox(height: 20),
HealthCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 25,
backgroundColor: Color(0xFFEAF7F7),
child: Icon(
Icons.child_care_rounded,
color: SgColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
measurement?.childName ?? 'Data Anak',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
const SizedBox(height: 4),
Text(
'${result.identitas.umurBulan.toStringAsFixed(0)} Bulan | ${_genderLabel(result.identitas.jenisKelamin)}',
style: AppTypography.caption,
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text('TERAKHIR UKUR', style: AppTypography.caption),
const SizedBox(height: 4),
Text(
measurement == null
? 'Hari ini'
: formatMeasurementDate(measurement.tanggalUkur),
style: AppTypography.h3,
),
],
),
],
),
),
const SizedBox(height: 24),
Row(
children: [
const Icon(
Icons.trending_up_rounded,
color: SgColors.primary,
size: 20,
),
const SizedBox(width: 8),
Text('Detail Indikator Gizi', style: AppTypography.h2),
],
),
const SizedBox(height: 16),
MetricProgress(
label: 'Berat Badan / Umur (BB/U)',
description: 'Mengukur berat terhadap usia',
status: result.kategori.bbu,
value: _scoreToProgress(result.zScore.bbu),
icon: Icons.monitor_weight_outlined,
),
MetricProgress(
label: 'Tinggi Badan / Umur (TB/U)',
description: 'Mengukur tinggi terhadap usia',
status: result.kategori.tbu,
value: _scoreToProgress(result.zScore.tbu),
icon: Icons.straighten_rounded,
),
MetricProgress(
label: 'Berat / Tinggi (BB/TB)',
description: 'Proporsi tubuh ideal',
status: result.kategori.bbtb,
value: _scoreToProgress(result.zScore.bbtb),
icon: Icons.verified_outlined,
),
const SizedBox(height: 8),
HealthCard(
color: const Color(0xFFF5FBFA),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: SgColors.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Catatan Nutrisi', style: AppTypography.h3),
const SizedBox(height: 8),
Text(
recommendationStatusExplanation(result.statusGabungan),
style: AppTypography.body,
),
],
),
),
],
),
),
],
),
),
bottomSheet: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
border: const Border(top: BorderSide(color: SgColors.border)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 16,
offset: const Offset(0, -8),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isNormal) ...[
HealthCard(
color: const Color(0xFFFFFBEB),
borderColor: const Color(0xFFF7E7C1),
child: Row(
children: const [
Icon(
Icons.warning_amber_rounded,
color: SgColors.warning,
),
SizedBox(width: 12),
Expanded(
child: Text(
'Status perlu perhatian. Konsultasi ahli gizi menjadi prioritas.',
style: AppTypography.body,
),
),
],
),
),
const SizedBox(height: 12),
],
if (!isNormal)
PrimaryButton(
label: 'Konsultasi Ahli Gizi',
icon: Icons.chat_bubble_outline_rounded,
onPressed: () {
Navigator.of(
context,
).push(fadeRoute(const ConsultationChatScreen()));
},
),
if (!isNormal) const SizedBox(height: 12),
PrimaryButton(
label: 'Lihat Rekomendasi Menu',
icon: Icons.restaurant_menu_rounded,
onPressed: () {
Navigator.of(context).push(
fadeRoute(
RecommendationScreen(
status: result.statusGabungan,
childId: measurement?.childId,
riwayatId: measurement?.id,
childName: measurement?.childName,
measuredAt: measurement?.tanggalUkur,
),
),
);
},
),
if (isNormal) ...[
const SizedBox(height: 12),
PrimaryButton(
label: 'Konsultasi Ahli Gizi',
icon: Icons.chat_bubble_outline_rounded,
isOutlined: true,
onPressed: () {
Navigator.of(
context,
).push(fadeRoute(const ConsultationChatScreen()));
},
),
],
],
),
),
),
);
}
double _scoreToProgress(double score) {
if (score.isNaN) return 0.62;
return ((score + 3) / 6).clamp(0.08, 0.96).toDouble();
}
String _genderLabel(String gender) {
if (gender.toLowerCase().startsWith('p')) return 'Perempuan';
return 'Laki-laki';
}
}

View File

@ -1,422 +0,0 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../models/riwayat_response_model.dart';
import '../services/api_service.dart';
import '../utils/nutrition_display_utils.dart';
import 'riwayat_detail_screen.dart';
class RiwayatScreen extends StatefulWidget {
const RiwayatScreen({super.key, required this.childId});
final int childId;
@override
State<RiwayatScreen> createState() => _RiwayatScreenState();
}
class _RiwayatScreenState extends State<RiwayatScreen> {
final ApiService _apiService = ApiService();
late Future<RiwayatResponseModel> _future;
@override
void initState() {
super.initState();
_future = _apiService.getRiwayat(childId: widget.childId);
}
void _retry() {
setState(() {
_future = _apiService.getRiwayat(childId: widget.childId);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(title: const Text('Riwayat Gizi')),
body: FutureBuilder<RiwayatResponseModel>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _HistorySkeleton();
}
if (snapshot.hasError) {
return ErrorState(
message:
'Riwayat belum dapat dimuat. Pastikan server API aktif dan koneksi tersedia.',
onRetry: _retry,
);
}
final data = snapshot.data!;
if (data.riwayat.isEmpty) {
return EmptyState(
title: 'Belum Ada Riwayat',
message:
'Input pengukuran pertama untuk mulai melihat timeline pertumbuhan anak.',
actionLabel: 'Muat Ulang',
onAction: _retry,
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
children: [
HealthCard(
color: const Color(0xFFEAF7F7),
borderColor: const Color(0xFFCBEAEA),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChildAvatar(
name: data.child.nama,
gender: data.child.jenisKelamin,
photoUrl: data.child.photoUrl,
radius: 28,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.child.nama,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h2,
),
const SizedBox(height: 4),
Text(
'${genderLabel(data.child.jenisKelamin)} | ${formatAgeFromBirthDate(data.child.tanggalLahir)}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
const SizedBox(height: 8),
Text(
'Timeline pengukuran tersusun dari tanggal ukur terbaru.',
style: AppTypography.body,
),
],
),
),
const SizedBox(width: 12),
StatusBadge(text: '${data.riwayat.length} Entri'),
],
),
),
const SizedBox(height: 24),
...data.riwayat.asMap().entries.map((entry) {
return _TimelineItem(
child: data.child,
item: entry.value,
isLast: entry.key == data.riwayat.length - 1,
);
}),
],
);
},
),
);
}
}
class _TimelineItem extends StatelessWidget {
const _TimelineItem({
required this.child,
required this.item,
required this.isLast,
});
final ChildInfoModel child;
final RiwayatItemModel item;
final bool isLast;
@override
Widget build(BuildContext context) {
final visual = nutritionStatusVisual(item.statusGabungan);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.12),
shape: BoxShape.circle,
border: Border.all(color: visual.color.withValues(alpha: 0.4)),
),
child: Icon(visual.icon, color: visual.color, size: 12),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: SgColors.border,
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.calendar_today_outlined,
size: 16,
color: SgColors.textSecondary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
formatMeasurementDate(item.tanggalUkur),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 0.4,
),
),
),
],
),
const SizedBox(height: 10),
HealthCard(
onTap: () {
Navigator.of(context).push(
fadeRoute(
RiwayatDetailScreen(child: child, item: item),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChildAvatar(
name: child.nama,
gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 22,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
child.nama,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
const SizedBox(height: 4),
Text(
'Usia ${formatAgeFromMonths(item.umurBulan)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
],
),
),
const SizedBox(width: 8),
Flexible(
child: StatusBadge(
text: item.statusGabungan,
color: visual.color,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _MeasurementInfo(
icon: Icons.monitor_weight_outlined,
label: 'Berat badan',
value: '${item.berat.toStringAsFixed(1)} kg',
),
),
const SizedBox(width: 12),
Expanded(
child: _MeasurementInfo(
icon: Icons.straighten_rounded,
label: 'Tinggi badan',
value: '${item.tinggi.toStringAsFixed(1)} cm',
),
),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FBFA),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SgColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
visual.icon,
size: 18,
color: visual.color,
),
const SizedBox(width: 10),
Expanded(
child: Text(
visual.summary,
style: AppTypography.body,
),
),
],
),
),
const SizedBox(height: 12),
Row(
children: const [
Icon(
Icons.info_outline_rounded,
size: 16,
color: SgColors.textSecondary,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Ketuk untuk detail analisis',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption,
),
),
Icon(
Icons.chevron_right_rounded,
color: SgColors.textSecondary,
),
],
),
],
),
),
],
),
),
),
],
),
);
}
}
class _MeasurementInfo extends StatelessWidget {
const _MeasurementInfo({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 20,
backgroundColor: const Color(0xFFEAF7F7),
child: Icon(icon, color: SgColors.primary, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.caption),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
],
),
),
],
);
}
}
class _HistorySkeleton extends StatelessWidget {
const _HistorySkeleton();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(24),
children: [
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 180, height: 18, color: const Color(0xFFE9EEEC)),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: 12,
color: const Color(0xFFE9EEEC),
),
],
),
),
const SizedBox(height: 24),
for (var i = 0; i < 3; i++) ...[
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 16,
color: const Color(0xFFE9EEEC),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
height: 12,
color: const Color(0xFFE9EEEC),
),
const SizedBox(height: 10),
Container(
width: 180,
height: 12,
color: const Color(0xFFE9EEEC),
),
],
),
),
const SizedBox(height: 16),
],
],
);
}
}

View File

@ -1,235 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../app_design.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({super.key});
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _oldPassword = TextEditingController();
final _newPassword = TextEditingController();
final _confirmPassword = TextEditingController();
final _otpController = TextEditingController();
bool _hideOld = true;
bool _hideNew = true;
bool _hideConfirm = true;
bool _savingPassword = false;
@override
void dispose() {
_oldPassword.dispose();
_newPassword.dispose();
_confirmPassword.dispose();
_otpController.dispose();
super.dispose();
}
String get _strength {
final value = _newPassword.text;
if (value.length >= 10 && RegExp(r'[A-Z]').hasMatch(value) && RegExp(r'[0-9]').hasMatch(value)) {
return 'Kuat';
}
if (value.length >= 7) return 'Sedang';
return 'Lemah';
}
Color get _strengthColor {
if (_strength == 'Kuat') return const Color(0xFF34A853);
if (_strength == 'Sedang') return const Color(0xFFF59E0B);
return const Color(0xFFE53935);
}
Future<void> _savePassword() async {
if (_newPassword.text.trim().isEmpty || _newPassword.text != _confirmPassword.text) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Konfirmasi password tidak sesuai.')),
);
return;
}
setState(() => _savingPassword = true);
await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted) return;
setState(() => _savingPassword = false);
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Berhasil'),
content: const Text('Password berhasil diperbarui.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
title: const Text('Privasi & Keamanan'),
backgroundColor: const Color(0xFFF5F7F6),
),
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ubah Password', style: AppTypography.h2.copyWith(color: SgColors.textPrimary)),
const SizedBox(height: 10),
HealthCard(
child: Column(
children: [
_PasswordField(
controller: _oldPassword,
label: 'Password Lama',
hidden: _hideOld,
onToggle: () => setState(() => _hideOld = !_hideOld),
),
const SizedBox(height: 10),
_PasswordField(
controller: _newPassword,
label: 'Password Baru',
hidden: _hideNew,
onToggle: () => setState(() => _hideNew = !_hideNew),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Row(
children: [
Text('Kekuatan password: ', style: AppTypography.caption),
Text(
_strength,
style: AppTypography.caption.copyWith(color: _strengthColor, fontWeight: FontWeight.w800),
),
],
),
const SizedBox(height: 10),
_PasswordField(
controller: _confirmPassword,
label: 'Konfirmasi Password',
hidden: _hideConfirm,
onToggle: () => setState(() => _hideConfirm = !_hideConfirm),
),
const SizedBox(height: 14),
PrimaryButton(
label: _savingPassword ? 'Menyimpan...' : 'Simpan Password',
icon: LucideIcons.arrowRight,
onPressed: _savingPassword ? null : _savePassword,
),
],
),
),
const SizedBox(height: 16),
const Text('Verifikasi Nomor Telepon', style: AppTypography.h2),
const SizedBox(height: 10),
HealthCard(
child: Column(
children: [
TextField(
controller: _otpController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Kode OTP',
prefixIcon: const Icon(LucideIcons.shield, color: Color(0xFF0B7A86)),
filled: true,
fillColor: const Color(0xFFF7FAF9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFE3EAE8)),
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _snack(context, 'OTP berhasil dikirim ulang.'),
child: const Text('Resend OTP'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: () => _snack(context, 'Nomor berhasil diverifikasi.'),
child: const Text('Verifikasi'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
const Text('Kebijakan Privasi', style: AppTypography.h2),
const SizedBox(height: 10),
const HealthCard(
child: Text(
'Privasi data pengguna S-Gizi dilindungi dan hanya digunakan untuk kebutuhan layanan monitoring dan edukasi gizi.',
style: AppTypography.body,
),
),
],
),
),
),
).animate().fadeIn(duration: 220.ms).slideY(begin: 0.02, end: 0);
}
}
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.label,
required this.hidden,
required this.onToggle,
this.onChanged,
});
final TextEditingController controller;
final String label;
final bool hidden;
final VoidCallback onToggle;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
obscureText: hidden,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: const Icon(LucideIcons.shield, color: Color(0xFF0B7A86)),
suffixIcon: IconButton(
onPressed: onToggle,
icon: Icon(hidden ? LucideIcons.eye : LucideIcons.eyeOff),
),
filled: true,
fillColor: const Color(0xFFF7FAF9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFE3EAE8)),
),
),
);
}
}
void _snack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}

View File

@ -3,7 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_design.dart'; import '../app_design.dart';
import 'onboarding_screen.dart'; import '../app_state.dart';
import '../features/auth/screens/onboarding_screen.dart';
import '../features/dashboard/screens/parent_dashboard_screen.dart';
import '../features/nutritionist/screens/nutritionist_dashboard_screen.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -25,20 +28,31 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1200), duration: const Duration(milliseconds: 1200),
)..forward(); )..forward();
_imageScale = Tween<double>(begin: 1.22, end: 1).animate( _imageScale = Tween<double>(
CurvedAnimation( begin: 1.22,
parent: _controller, end: 1,
curve: Curves.easeOutCubic, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
), _imageOpacity = Tween<double>(
); begin: 0.2,
_imageOpacity = Tween<double>(begin: 0.2, end: 1).animate( end: 1,
CurvedAnimation(parent: _controller, curve: Curves.easeOut), ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
);
Timer(const Duration(milliseconds: 1500), () { Timer(const Duration(milliseconds: 1500), _openNextScreen);
}
Future<void> _openNextScreen() async {
await SgiziAppState.instance.restoreSession();
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pushReplacement(fadeRoute(const OnboardingScreen()));
}); final state = SgiziAppState.instance;
Widget next = const OnboardingScreen();
if (state.isAuthenticated) {
next = state.role == 'nutritionist'
? const NutritionistDashboardScreen()
: const ParentDashboardScreen();
}
Navigator.of(context).pushReplacement(fadeRoute(next));
} }
@override @override
@ -87,7 +101,9 @@ class _SplashScreenState extends State<SplashScreen>
borderRadius: BorderRadius.circular(40), borderRadius: BorderRadius.circular(40),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFF4B8E96).withValues(alpha: 0.24), color: const Color(
0xFF4B8E96,
).withValues(alpha: 0.24),
blurRadius: 42, blurRadius: 42,
spreadRadius: 4, spreadRadius: 4,
), ),
@ -99,7 +115,7 @@ class _SplashScreenState extends State<SplashScreen>
'assets/image/Logo_SplashScreen.png', 'assets/image/Logo_SplashScreen.png',
fit: BoxFit.cover, fit: BoxFit.cover,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
errorBuilder: (_, __, ___) => Image.asset( errorBuilder: (_, _, _) => Image.asset(
'assets/image/logo_sgizi.png', 'assets/image/logo_sgizi.png',
fit: BoxFit.contain, fit: BoxFit.contain,
), ),

View File

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../app_state.dart'; import 'package:s_gizi/app_state.dart';
import '../models/api_result_model.dart'; import 'package:s_gizi/models/api_result_model.dart';
import '../models/mobile_child_model.dart'; import 'package:s_gizi/models/mobile_child_model.dart';
import '../models/news_article_model.dart'; import 'package:s_gizi/models/news_article_model.dart';
import '../models/recommendation_response_model.dart'; import 'package:s_gizi/models/recommendation_response_model.dart';
import '../models/riwayat_response_model.dart'; import 'package:s_gizi/models/riwayat_response_model.dart';
class ApiService { class ApiService {
ApiService({http.Client? client, String? baseUrl}) ApiService({http.Client? client, String? baseUrl})
@ -19,15 +20,16 @@ class ApiService {
final String baseUrl; final String baseUrl;
static String _resolveDefaultBaseUrl() { static String _resolveDefaultBaseUrl() {
// Web -> localhost browser, Android emulator -> 10.0.2.2 const configured = String.fromEnvironment('SGIZI_API_BASE_URL');
if (kIsWeb) { if (configured.isNotEmpty) return configured;
return 'http://127.0.0.1:8000/api';
}
return 'http://10.0.2.2:8000/api';
//192.168.1.77//
//10.0.2.2 // ip address android emulator
}
// Override for physical devices:
// flutter run --dart-define=SGIZI_API_BASE_URL=http://<LAN-IP>:8000/api
if (kIsWeb) {
return 'http://192.168.1.69:8000/api';
}
return 'http://192.168.1.69:8000/api';
}
Future<Map<String, dynamic>> hitungGizi(Map<String, dynamic> data) async { Future<Map<String, dynamic>> hitungGizi(Map<String, dynamic> data) async {
final response = await _client.post( final response = await _client.post(
@ -47,9 +49,121 @@ class ApiService {
throw Exception('Gagal API (${response.statusCode}): ${response.body}'); throw Exception('Gagal API (${response.statusCode}): ${response.body}');
} }
Future<void> sendOtp(String phone) async { Future<Map<String, dynamic>> login({
required String phone,
required String password,
}) async {
final response = await _client.post( final response = await _client.post(
Uri.parse('$baseUrl/send-otp'), Uri.parse('$baseUrl/auth/login'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({'phone': phone, 'password': password}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Login gagal.'));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format response login tidak valid.');
}
final token = decoded['token'] as String? ?? '';
if (token.isEmpty) throw Exception('Token login kosong.');
final userJson = decoded['user'];
if (userJson is Map<String, dynamic>) {
await SgiziAppState.instance.saveSession(
token: token,
role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(),
user: Map<String, dynamic>.from(userJson),
);
}
return decoded;
}
String _messageFromBody(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) return message;
}
} catch (_) {}
return fallback;
}
Future<void> registerSendOtp({
required String name,
required String phone,
required String parentGender,
required String password,
required String passwordConfirmation,
}) async {
final response = await _client.post(
Uri.parse('$baseUrl/auth/register/send-otp'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'name': name,
'phone': phone,
'parent_gender': parentGender,
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.'));
}
}
Future<Map<String, dynamic>> registerVerifyOtp({
required String phone,
required String otp,
}) async {
final response = await _client.post(
Uri.parse('$baseUrl/auth/register/verify'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({'phone': phone, 'otp': otp}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'OTP tidak valid.'));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format response verifikasi tidak valid.');
}
final token = decoded['token'] as String? ?? '';
if (token.isEmpty) throw Exception('Token registrasi kosong.');
final userJson = decoded['user'];
if (userJson is Map<String, dynamic>) {
await SgiziAppState.instance.saveSession(
token: token,
role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(),
user: Map<String, dynamic>.from(userJson),
);
}
return decoded;
}
Future<void> forgotPassword({required String phone}) async {
final response = await _client.post(
Uri.parse('$baseUrl/auth/forgot-password'),
headers: const { headers: const {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
@ -58,13 +172,62 @@ class ApiService {
); );
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengirim OTP: ${response.body}'); throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.'));
} }
} }
Future<String> verifyOtp(String phone, String otp) async { Future<Map<String, dynamic>> resetPassword({
required String phone,
required String otp,
required String password,
required String passwordConfirmation,
}) async {
final response = await _client.post( final response = await _client.post(
Uri.parse('$baseUrl/verify-otp'), Uri.parse('$baseUrl/auth/reset-password'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'phone': phone,
'otp': otp,
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Gagal reset password.'));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format response reset password tidak valid.');
}
final token = decoded['token'] as String? ?? '';
if (token.isNotEmpty) {
final userJson = decoded['user'];
if (userJson is Map<String, dynamic>) {
await SgiziAppState.instance.saveSession(
token: token,
role: (userJson['role'] as String? ?? 'orang_tua')
.trim()
.toLowerCase(),
user: Map<String, dynamic>.from(userJson),
);
}
}
return decoded;
}
Future<Map<String, dynamic>> verifyForgotPasswordOtp({
required String phone,
required String otp,
}) async {
final response = await _client.post(
Uri.parse('$baseUrl/auth/forgot-password/verify'),
headers: const { headers: const {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
@ -72,24 +235,21 @@ class ApiService {
body: jsonEncode({'phone': phone, 'otp': otp}), body: jsonEncode({'phone': phone, 'otp': otp}),
); );
if (response.statusCode == 200) { if (response.statusCode < 200 || response.statusCode >= 300) {
final decoded = jsonDecode(response.body); throw Exception(_messageFromBody(response.body, 'Gagal verifikasi OTP.'));
if (decoded is! Map<String, dynamic>) {
throw Exception('Format response API tidak valid.');
}
final token = decoded['token'] as String? ?? '';
if (token.isEmpty) throw Exception('Token login kosong.');
return token;
} }
throw Exception('OTP tidak valid: ${response.body}'); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format response verifikasi OTP tidak valid.');
}
return decoded;
} }
Future<List<MobileChildModel>> getChildren() async { Future<List<MobileChildModel>> getChildren() async {
final response = await _client.get( final response = await _client
Uri.parse('$baseUrl/children'), .get(Uri.parse('$baseUrl/children'), headers: _headers())
headers: _headers(), .timeout(const Duration(seconds: 12));
);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil data anak: ${response.body}'); throw Exception('Gagal mengambil data anak: ${response.body}');
@ -138,8 +298,10 @@ class ApiService {
body: jsonEncode({ body: jsonEncode({
if (data['nama'] != null) 'nama': data['nama'], if (data['nama'] != null) 'nama': data['nama'],
if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'], if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'],
if (data['tanggal_lahir'] != null) 'tanggal_lahir': data['tanggal_lahir'], if (data['tanggal_lahir'] != null)
if (data['jenis_kelamin'] != null) 'jenis_kelamin': data['jenis_kelamin'], 'tanggal_lahir': data['tanggal_lahir'],
if (data['jenis_kelamin'] != null)
'jenis_kelamin': data['jenis_kelamin'],
}), }),
); );
@ -147,7 +309,8 @@ class ApiService {
throw Exception('Gagal memperbarui data anak: ${response.body}'); throw Exception('Gagal memperbarui data anak: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic> ||
decoded['data'] is! Map<String, dynamic>) {
throw Exception('Format response update anak tidak valid.'); throw Exception('Format response update anak tidak valid.');
} }
return MobileChildModel.fromJson(decoded['data'] as Map<String, dynamic>); return MobileChildModel.fromJson(decoded['data'] as Map<String, dynamic>);
@ -169,10 +332,9 @@ class ApiService {
} }
Future<RiwayatResponseModel> getRiwayat({required int childId}) async { Future<RiwayatResponseModel> getRiwayat({required int childId}) async {
final response = await _client.get( final response = await _client
Uri.parse('$baseUrl/riwayat/$childId'), .get(Uri.parse('$baseUrl/riwayat/$childId'), headers: _headers())
headers: _headers(), .timeout(const Duration(seconds: 12));
);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception( throw Exception(
@ -193,17 +355,16 @@ class ApiService {
int? childId, int? childId,
int? riwayatId, int? riwayatId,
}) async { }) async {
final encodedStatus = Uri.encodeComponent(status.isEmpty ? 'latest' : status); final encodedStatus = Uri.encodeComponent(
status.isEmpty ? 'latest' : status,
);
final uri = Uri.parse('$baseUrl/rekomendasi/$encodedStatus').replace( final uri = Uri.parse('$baseUrl/rekomendasi/$encodedStatus').replace(
queryParameters: { queryParameters: {
if (childId != null) 'child_id': '$childId', if (childId != null) 'child_id': '$childId',
if (riwayatId != null) 'riwayat_id': '$riwayatId', if (riwayatId != null) 'riwayat_id': '$riwayatId',
}, },
); );
final response = await _client.get( final response = await _client.get(uri, headers: _headers());
uri,
headers: _headers(),
);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception( throw Exception(
@ -224,7 +385,9 @@ class ApiService {
}) async { }) async {
// Online/Internet (Google News RSS) tetap lewat endpoint `/news` // Online/Internet (Google News RSS) tetap lewat endpoint `/news`
// agar tidak bentrok dengan `/articles` yang sekarang khusus DB admin. // agar tidak bentrok dengan `/articles` yang sekarang khusus DB admin.
final uri = Uri.parse('$baseUrl/news').replace(queryParameters: {'q': query}); final uri = Uri.parse(
'$baseUrl/news',
).replace(queryParameters: {'q': query});
final response = await _client.get(uri, headers: _headers()); final response = await _client.get(uri, headers: _headers());
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception( throw Exception(
@ -244,15 +407,15 @@ class ApiService {
} }
Future<Map<String, dynamic>> getProfile() async { Future<Map<String, dynamic>> getProfile() async {
final response = await _client.get( final response = await _client
Uri.parse('$baseUrl/profile'), .get(Uri.parse('$baseUrl/profile'), headers: _headers())
headers: _headers(), .timeout(const Duration(seconds: 12));
);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil profil: ${response.body}'); throw Exception('Gagal mengambil profil: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic> ||
decoded['data'] is! Map<String, dynamic>) {
throw Exception('Format profil tidak valid.'); throw Exception('Format profil tidak valid.');
} }
return decoded['data'] as Map<String, dynamic>; return decoded['data'] as Map<String, dynamic>;
@ -262,6 +425,11 @@ class ApiService {
required String name, required String name,
required String phone, required String phone,
String? email, String? email,
String? gender,
String? birthDate,
String? specialization,
String? experience,
String? strSip,
}) async { }) async {
final response = await _client.put( final response = await _client.put(
Uri.parse('$baseUrl/profile'), Uri.parse('$baseUrl/profile'),
@ -270,18 +438,70 @@ class ApiService {
'name': name, 'name': name,
'phone': phone, 'phone': phone,
'email': (email ?? '').trim().isEmpty ? null : email?.trim(), 'email': (email ?? '').trim().isEmpty ? null : email?.trim(),
if ((gender ?? '').trim().isNotEmpty) 'parent_gender': gender!.trim(),
if ((birthDate ?? '').trim().isNotEmpty)
'tanggal_lahir': birthDate!.trim(),
if ((specialization ?? '').trim().isNotEmpty)
'specialization': specialization!.trim(),
if ((experience ?? '').trim().isNotEmpty)
'experience': experience!.trim(),
if ((strSip ?? '').trim().isNotEmpty) 'str_sip': strSip!.trim(),
}), }),
); );
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal memperbarui profil: ${response.body}'); throw Exception('Gagal memperbarui profil: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic> ||
decoded['data'] is! Map<String, dynamic>) {
throw Exception('Format update profil tidak valid.'); throw Exception('Format update profil tidak valid.');
} }
return decoded['data'] as Map<String, dynamic>; return decoded['data'] as Map<String, dynamic>;
} }
Future<void> updatePassword({
required String oldPassword,
required String newPassword,
required String newPasswordConfirmation,
}) async {
final response = await _client.put(
Uri.parse('$baseUrl/profile/password'),
headers: _headers(),
body: jsonEncode({
'old_password': oldPassword,
'password': newPassword,
'password_confirmation': newPasswordConfirmation,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Password lama tidak sesuai.'),
);
}
}
Future<void> logoutAllDevices() async {
final response = await _client.post(
Uri.parse('$baseUrl/profile/logout-all'),
headers: _headers(),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Gagal logout semua perangkat.'),
);
}
}
Future<void> deleteAccount() async {
final response = await _client.delete(
Uri.parse('$baseUrl/profile'),
headers: _headers(),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Gagal menghapus akun.'));
}
}
/// Ambil artikel dari database `s_gizi` (tanpa query News API). /// Ambil artikel dari database `s_gizi` (tanpa query News API).
Future<List<NewsArticleModel>> getArticlesDb() async { Future<List<NewsArticleModel>> getArticlesDb() async {
final uri = Uri.parse('$baseUrl/articles'); final uri = Uri.parse('$baseUrl/articles');
@ -306,8 +526,9 @@ class ApiService {
Future<List<Map<String, dynamic>>> getConsultationRooms({ Future<List<Map<String, dynamic>>> getConsultationRooms({
required int childId, required int childId,
}) async { }) async {
final uri = Uri.parse('$baseUrl/consultation/rooms') final uri = Uri.parse(
.replace(queryParameters: {'child_id': '$childId'}); '$baseUrl/consultation/rooms',
).replace(queryParameters: {'child_id': '$childId'});
final response = await _client.get(uri, headers: _headers()); final response = await _client.get(uri, headers: _headers());
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil room konsultasi: ${response.body}'); throw Exception('Gagal mengambil room konsultasi: ${response.body}');
@ -318,6 +539,21 @@ class ApiService {
return data.whereType<Map<String, dynamic>>().toList(); return data.whereType<Map<String, dynamic>>().toList();
} }
Future<List<Map<String, dynamic>>> getNutritionists() async {
final response = await _client
.get(Uri.parse('$baseUrl/nutritionists'), headers: _headers())
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Gagal memuat ahli gizi.'),
);
}
final decoded = jsonDecode(response.body);
final data = decoded is Map<String, dynamic> ? decoded['data'] : null;
if (data is! List) return const [];
return data.whereType<Map<String, dynamic>>().toList();
}
Future<Map<String, dynamic>> openConsultationRoom({ Future<Map<String, dynamic>> openConsultationRoom({
required int childId, required int childId,
required String expertId, required String expertId,
@ -342,7 +578,8 @@ class ApiService {
throw Exception('Gagal membuka room konsultasi: ${response.body}'); throw Exception('Gagal membuka room konsultasi: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic> ||
decoded['data'] is! Map<String, dynamic>) {
throw Exception('Format room konsultasi tidak valid.'); throw Exception('Format room konsultasi tidak valid.');
} }
return decoded['data'] as Map<String, dynamic>; return decoded['data'] as Map<String, dynamic>;
@ -367,20 +604,34 @@ class ApiService {
Future<Map<String, dynamic>> sendConsultationMessage({ Future<Map<String, dynamic>> sendConsultationMessage({
required int roomId, required int roomId,
required String message, required String message,
int? measurementId,
}) async { }) async {
final body = <String, dynamic>{'message': message};
if (measurementId != null) {
body['measurement_id'] = measurementId;
}
final response = await _client.post( final response = await _client.post(
Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'), Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'),
headers: _headers(), headers: _headers(),
body: jsonEncode({'message': message}), body: jsonEncode(body),
);
debugPrint(
'[sendConsultationMessage] status=${response.statusCode} body=${response.body}',
); );
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal kirim pesan konsultasi: ${response.body}'); throw Exception('Gagal kirim pesan konsultasi: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
throw Exception('Format response kirim pesan tidak valid.'); throw Exception('Format response kirim pesan tidak valid.');
} }
return decoded['data'] as Map<String, dynamic>; final data = decoded['data'];
if (data is Map<String, dynamic>) return data;
final status = decoded['status'] == true || decoded['success'] == true;
if (status || decoded.containsKey('message')) return decoded;
throw Exception('Format response kirim pesan tidak valid.');
} }
Future<Map<String, dynamic>> sendConsultationExpertReply({ Future<Map<String, dynamic>> sendConsultationExpertReply({
@ -396,12 +647,81 @@ class ApiService {
throw Exception('Gagal kirim balasan ahli: ${response.body}'); throw Exception('Gagal kirim balasan ahli: ${response.body}');
} }
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic> ||
decoded['data'] is! Map<String, dynamic>) {
throw Exception('Format response balasan ahli tidak valid.'); throw Exception('Format response balasan ahli tidak valid.');
} }
return decoded['data'] as Map<String, dynamic>; return decoded['data'] as Map<String, dynamic>;
} }
Future<Map<String, dynamic>> getNutritionistDashboard() async {
final response = await _client
.get(Uri.parse('$baseUrl/nutritionist/dashboard'), headers: _headers())
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Gagal memuat dashboard ahli gizi.'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format dashboard ahli gizi tidak valid.');
}
return decoded;
}
Future<Map<String, dynamic>> getNutritionistRoomMessages({
required int roomId,
}) async {
final response = await _client
.get(
Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'),
headers: _headers(),
)
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Gagal memuat chat konsultasi.'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format chat konsultasi tidak valid.');
}
return decoded;
}
Future<Map<String, dynamic>> sendNutritionistMessage({
required int roomId,
required String message,
}) async {
final response = await _client.post(
Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'),
headers: _headers(),
body: jsonEncode({'message': message}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Gagal mengirim pesan.'));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Format kirim pesan tidak valid.');
}
return decoded;
}
Future<void> closeNutritionistConsultation({required int roomId}) async {
final response = await _client.post(
Uri.parse('$baseUrl/nutritionist/consultations/$roomId/close'),
headers: _headers(),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
_messageFromBody(response.body, 'Gagal menandai konsultasi selesai.'),
);
}
}
Future<void> updateConsultationRoomStatus({ Future<void> updateConsultationRoomStatus({
required int roomId, required int roomId,
required String status, required String status,
@ -416,6 +736,20 @@ class ApiService {
} }
} }
Future<void> markConsultationMeasurementShared({
required int roomId,
required int measurementId,
}) async {
final response = await _client.patch(
Uri.parse('$baseUrl/consultation/rooms/$roomId/shared-measurement'),
headers: _headers(),
body: jsonEncode({'measurement_id': measurementId}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal menandai update perkembangan: ${response.body}');
}
}
Map<String, String> _headers() { Map<String, String> _headers() {
final token = SgiziAppState.instance.authToken; final token = SgiziAppState.instance.authToken;
return { return {

Some files were not shown because too many files have changed in this diff Show More