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.
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:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

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

View File

@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="s_gizi"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
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';
/// 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 {
const SgColors._();
static const primary = Color(0xFF4B8E96);
static const primaryTeal = Color(0xFF0B7A86);
static const primaryDark = Color(0xFF2F737A);
static const secondary = Color(0xFFA8D5BA);
static const background = Color(0xFFF5F7F6);
@ -71,7 +83,7 @@ class AppLogo extends StatelessWidget {
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
errorBuilder: (_, _, _) => Container(
width: size,
height: size,
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 {
const ChildAvatar({
super.key,
required this.name,
required this.gender,
this.photoUrl,
this.radius = 28,
});
final String name;
final String gender;
final String? photoUrl;
final double radius;
@override
Widget build(BuildContext context) {
final imageUrl = photoUrl?.trim();
if (imageUrl != null && imageUrl.isNotEmpty) {
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFFD9EEE7),
backgroundImage: NetworkImage(imageUrl),
);
}
final isFemale = gender.toLowerCase().startsWith('p');
final accent = isFemale ? const Color(0xFFFF8FA3) : SgColors.primary;
return CircleAvatar(
return SgAvatar(
name: name,
gender: gender,
radius: radius,
backgroundColor: accent.withValues(alpha: 0.12),
child: Icon(
isFemale ? Icons.face_3_rounded : Icons.face_rounded,
color: accent,
size: radius,
),
icon: Icons.child_care_rounded,
);
}
}
@ -141,11 +212,12 @@ class HealthCard extends StatelessWidget {
const HealthCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.padding = const EdgeInsets.all(SgSpacing.cardPad),
this.margin,
this.color = SgColors.surface,
this.borderColor,
this.onTap,
this.dense = false,
});
final Widget child;
@ -154,9 +226,11 @@ class HealthCard extends StatelessWidget {
final Color color;
final Color? borderColor;
final VoidCallback? onTap;
final bool dense;
@override
Widget build(BuildContext context) {
final radius = dense ? 16.0 : 18.0;
final card = AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
@ -164,13 +238,13 @@ class HealthCard extends StatelessWidget {
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? SgColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 10),
color: Colors.black.withValues(alpha: dense ? 0.04 : 0.06),
blurRadius: dense ? 12 : 16,
offset: Offset(0, dense ? 6 : 8),
),
],
),
@ -184,7 +258,7 @@ class HealthCard extends StatelessWidget {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(radius),
onTap: onTap,
child: card,
),
@ -197,25 +271,33 @@ class StatusBadge extends StatelessWidget {
super.key,
required this.text,
this.color = SgColors.success,
this.compact = false,
});
final String text;
final Color color;
final bool compact;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 10,
vertical: compact ? 4 : 6,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.36)),
border: Border.all(color: color.withValues(alpha: 0.32)),
),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
color: color,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
fontSize: compact ? 11 : 12,
),
),
);
@ -298,37 +380,50 @@ class EmptyState extends StatelessWidget {
required this.message,
this.actionLabel,
this.onAction,
this.icon = Icons.inbox_outlined,
this.assetImage,
});
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
final IconData icon;
final String? assetImage;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
child: HealthCard(
dense: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFE9F6F2),
child: Icon(Icons.inbox_outlined, color: SgColors.primary),
),
const SizedBox(height: 16),
if (assetImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
assetImage!,
height: 88,
width: 88,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _emptyIcon(icon),
),
)
else
_emptyIcon(icon),
const SizedBox(height: 12),
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
const SizedBox(height: 8),
const SizedBox(height: 6),
Text(
message,
style: AppTypography.body,
style: AppTypography.body.copyWith(fontSize: 13),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
const SizedBox(height: 12),
PrimaryButton(label: actionLabel!, onPressed: onAction),
],
],
@ -339,11 +434,29 @@ class EmptyState extends StatelessWidget {
}
}
class ErrorState extends StatelessWidget {
const ErrorState({super.key, required this.message, required this.onRetry});
Widget _emptyIcon(IconData icon) {
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 VoidCallback onRetry;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
@ -354,13 +467,13 @@ class ErrorState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFFFEBEE),
child: Icon(Icons.wifi_off_rounded, color: SgColors.danger),
backgroundColor: color.withValues(alpha: 0.12),
child: Icon(icon, color: color),
),
const SizedBox(height: 16),
const Text('Terjadi Kendala', style: AppTypography.h2),
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(
message,
@ -476,8 +589,8 @@ PageRouteBuilder<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>(
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
pageBuilder: (_, _, _) => page,
transitionsBuilder: (_, animation, _, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
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 {
SgiziAppState._();
@ -8,9 +11,12 @@ class SgiziAppState extends ChangeNotifier {
static final SgiziAppState instance = SgiziAppState._();
String? authToken;
String? role;
int? activeChildId;
bool showFamilyOverviewOnHome = true;
List<MobileChildModel> children = const [];
Map<String, dynamic>? profileData;
Map<String, dynamic>? userData;
bool get isAuthenticated => authToken != null && authToken!.isNotEmpty;
MobileChildModel? get activeChild {
@ -27,22 +33,81 @@ class SgiziAppState extends ChangeNotifier {
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) {
children = value;
activeChildId ??= value.isNotEmpty ? value.first.id : null;
if (value.every((child) => child.id != activeChildId)) {
activeChildId = value.isNotEmpty ? value.first.id : null;
if (value.isEmpty) {
activeChildId = 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();
}
void setProfileData(Map<String, dynamic> value) {
profileData = value;
userData = value;
notifyListeners();
}
void setActiveChild(int id) {
activeChildId = id;
showFamilyOverviewOnHome = false;
notifyListeners();
}
void showFamilyOverview() {
showFamilyOverviewOnHome = true;
notifyListeners();
}
void resetActiveChild() {
activeChildId = null;
showFamilyOverviewOnHome = true;
notifyListeners();
}
@ -64,11 +129,22 @@ class SgiziAppState extends ChangeNotifier {
notifyListeners();
}
void logout() {
Future<void> logout() async {
authToken = null;
role = null;
activeChildId = null;
showFamilyOverviewOnHome = true;
children = const [];
profileData = null;
userData = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_roleKey);
await prefs.remove(_userKey);
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:phosphor_flutter/phosphor_flutter.dart';
import '../app_design.dart';
import 'auth_screen.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/features/auth/screens/auth_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@ -52,7 +52,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.',
floatingTags: [
_FloatingTag(
label: 'Status Normal',
label: 'Status Gizi Baik',
icon: BootstrapIcons.heart,
align: Alignment(0.92, -0.48),
),
@ -154,7 +154,10 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
decoration: BoxDecoration(
gradient: i == _index
? const LinearGradient(
colors: [SgColors.primaryDark, SgColors.primary],
colors: [
SgColors.primaryDark,
SgColors.primary,
],
)
: null,
color: i == _index ? null : const Color(0xFFCAE8E2),
@ -232,10 +235,7 @@ class _OnboardingPage extends StatelessWidget {
child: Column(
children: [
const SizedBox(height: 20),
_HeroImageWithBadges(
imagePath: imagePath,
floatingTags: const [],
),
_HeroImageWithBadges(imagePath: imagePath, floatingTags: const []),
const SizedBox(height: 34),
Text.rich(
_buildTitleSpan(),
@ -319,7 +319,7 @@ class _HeroImageWithBadges extends StatelessWidget {
imagePath,
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorBuilder: (_, __, ___) => Container(
errorBuilder: (_, _, _) => Container(
color: const Color(0xFFE7F4F1),
alignment: Alignment.center,
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 '../app_design.dart';
import '../app_state.dart';
import 'add_child_screen.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/features/children/screens/add_child_screen.dart';
class ChildrenScreen extends StatelessWidget {
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 '../app_design.dart';
import '../models/riwayat_response_model.dart';
import '../utils/nutrition_display_utils.dart';
import 'consultation_chat_screen.dart';
import 'recommendation_screen.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/riwayat_response_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 RiwayatDetailScreen extends StatelessWidget {
const RiwayatDetailScreen({
@ -66,7 +66,7 @@ class RiwayatDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status Gizi: ${item.statusGabungan}',
'Status Gizi: ${localizeNutritionStatus(item.statusGabungan)}',
style: AppTypography.h2,
),
const SizedBox(height: 10),
@ -87,7 +87,6 @@ class RiwayatDetailScreen extends StatelessWidget {
ChildAvatar(
name: child.nama,
gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 28,
),
const SizedBox(width: 14),
@ -103,7 +102,11 @@ class RiwayatDetailScreen extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
formatAgeFromMonths(item.umurBulan),
formatAgeAtMeasurement(
birthDate: child.tanggalLahir,
measurementDate: item.tanggalUkur,
source: 'riwayat_detail_header',
),
style: AppTypography.caption,
),
const SizedBox(height: 2),
@ -124,8 +127,14 @@ class RiwayatDetailScreen extends StatelessWidget {
title: 'Data Pengukuran',
child: Column(
children: [
_InfoRow(label: 'Berat', value: '${item.berat.toStringAsFixed(1)} kg'),
_InfoRow(label: 'Tinggi', value: '${item.tinggi.toStringAsFixed(1)} cm'),
_InfoRow(
label: 'Berat',
value: '${item.berat.toStringAsFixed(1)} kg',
),
_InfoRow(
label: 'Tinggi',
value: '${item.tinggi.toStringAsFixed(1)} cm',
),
_InfoRow(
label: 'Cara',
value: measurementMethodLabel(item.caraUkur, item.umurBulan),
@ -293,7 +302,7 @@ class _ScoreRow extends StatelessWidget {
SizedBox(width: 64, child: Text(label, style: AppTypography.body)),
Expanded(
child: Text(
'$score -> $category',
'$score ${localizeNutritionStatus(category)}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
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

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

View File

@ -3,10 +3,11 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:shimmer/shimmer.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
import 'edit_profile_screen.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/utils/nutrition_display_utils.dart';
import 'package:s_gizi/features/profile/screens/edit_profile_screen.dart';
class AccountInfoScreen extends StatefulWidget {
const AccountInfoScreen({super.key});
@ -31,11 +32,11 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
}
void _retry() => setState(() {
_future = _api.getProfile().then((value) {
_state.setProfileData(value);
return value;
});
});
_future = _api.getProfile().then((value) {
_state.setProfileData(value);
return value;
});
});
@override
Widget build(BuildContext context) {
@ -58,11 +59,15 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
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 phone = (profile['phone'] 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(
physics: const BouncingScrollPhysics(),
@ -73,71 +78,48 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
HealthCard(
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
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,
),
SgAvatar(name: name, radius: 39, icon: Icons.person)
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -2, duration: 1800.ms),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
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 StatusBadge(text: 'Akun Terverifikasi', color: Color(0xFF0B7A86)),
const StatusBadge(
text: 'Akun Terverifikasi',
color: Color(0xFF0B7A86),
),
],
),
),
const SizedBox(width: 10),
IconButton(
onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
final updated = await Navigator.of(context)
.push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
if (updated != null && mounted) {
setState(() {
_profileCache = updated;
@ -146,7 +128,10 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
_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),
Text(
'Data Akun',
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
style: AppTypography.h2.copyWith(
color: SgColors.textPrimary,
),
),
const SizedBox(height: 10),
HealthCard(
@ -189,18 +176,19 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
),
const SizedBox(height: 16),
PrimaryButton(
label: 'Edit Profile',
label: 'Edit Profil',
icon: LucideIcons.arrowRight,
onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
final updated = await Navigator.of(context)
.push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
if (updated != null && mounted) {
setState(() {
_profileCache = updated;
@ -280,13 +268,24 @@ class _AccountSkeleton extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
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),
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:lucide_icons/lucide_icons.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/services/api_service.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({
@ -37,16 +37,22 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
_nameController = TextEditingController(text: widget.initialName);
_phoneController = TextEditingController(text: widget.initialPhone);
_emailController = TextEditingController(text: widget.initialEmail);
_nameController.addListener(_refreshAvatar);
}
@override
void dispose() {
_nameController.removeListener(_refreshAvatar);
_nameController.dispose();
_phoneController.dispose();
_emailController.dispose();
super.dispose();
}
void _refreshAvatar() {
if (mounted) setState(() {});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
@ -60,9 +66,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal menyimpan: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Gagal menyimpan: $e')));
return;
}
if (!mounted) return;
@ -78,8 +84,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
resizeToAvoidBottomInset: true,
appBar: AppBar(
title: const Text('Edit Profile'),
title: const Text('Edit Profil'),
backgroundColor: const Color(0xFFF5F7F6),
),
body: SafeArea(
@ -95,62 +102,20 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
child: Center(
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 98,
height: 98,
decoration: BoxDecoration(
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(
SgAvatar(
name: _nameController.text,
radius: 50,
icon: Icons.person_rounded,
)
.animate(onPlay: (c) => c.repeat(reverse: true))
.scale(
begin: const Offset(0.99, 0.99),
end: const Offset(1.02, 1.02),
duration: 1700.ms,
),
const SizedBox(height: 10),
Text(
'Ubah Foto Profile',
getInitialName(_nameController.text),
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
@ -165,14 +130,18 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
controller: _nameController,
label: 'Nama Lengkap',
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),
_InputField(
controller: _phoneController,
label: 'Nomor Telepon',
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),
_InputField(
@ -182,7 +151,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
validator: (v) {
final value = (v ?? '').trim();
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),
@ -207,23 +178,34 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Simpan Perubahan',
style: AppTypography.h3.copyWith(color: Colors.white),
style: AppTypography.h3.copyWith(
color: Colors.white,
),
),
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:url_launcher/url_launcher.dart';
import '../app_design.dart';
import 'package:s_gizi/app_design.dart';
class HelpScreen extends StatefulWidget {
const HelpScreen({super.key});
@ -62,14 +62,16 @@ class _HelpScreenState extends State<HelpScreen> {
icon: LucideIcons.messageCircle,
title: 'WhatsApp Admin',
subtitle: '081249583765',
onTap: () => _launch(context, 'https://wa.me/6281249583765'),
onTap: () =>
_launch(context, 'https://wa.me/6281249583765'),
),
const SizedBox(height: 8),
_ContactButton(
icon: LucideIcons.info,
title: 'Email',
subtitle: 'smartgiziapp@gmail.com',
onTap: () => _launch(context, 'mailto:smartgiziapp@gmail.com'),
onTap: () =>
_launch(context, 'mailto:smartgiziapp@gmail.com'),
),
const SizedBox(height: 8),
_ContactButton(
@ -133,7 +135,9 @@ class _FaqCardState extends State<_FaqCard> {
padding: const EdgeInsets.only(top: 10),
child: Text(widget.answer, style: AppTypography.body),
),
crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
crossFadeState: _expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
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 'app_design.dart';
import 'services/local_notification_service.dart';
import 'screens/splash_screen.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/services/local_notification_service.dart';
import 'package:s_gizi/features/auth/screens/splash_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalNotificationService.instance.init();
runApp(const GiziApp());
}

View File

@ -47,6 +47,12 @@ class AnalysisMeasurementModel {
required this.childName,
required this.tanggalUkur,
required this.caraUkur,
this.isAnomaly = false,
this.dataStatus = 'normal',
this.validationStatus = 'valid',
this.validationNote = '',
this.monitoringStatus = 'normal',
this.isConfirmedByParent = false,
});
final int id;
@ -54,6 +60,12 @@ class AnalysisMeasurementModel {
final String childName;
final String tanggalUkur;
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) {
return AnalysisMeasurementModel(
@ -62,15 +74,30 @@ class AnalysisMeasurementModel {
childName: json['child_name'] as String? ?? '-',
tanggalUkur: json['tanggal_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 {
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 String jenisKelamin;
final String caraUkur;
final String standarBbtb;
final int? umurHari;
factory IdentitasModel.fromJson(Map<String, dynamic> json) {
double parseNum(dynamic v) {
@ -81,6 +108,9 @@ class IdentitasModel {
return IdentitasModel(
umurBulan: parseNum(json['umur_bulan']),
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.karbohidrat,
required this.alasan,
this.thumbnail,
});
final String menu;
@ -142,6 +173,7 @@ class RekomendasiModel {
final int lemak;
final int karbohidrat;
final String alasan;
final String? thumbnail;
factory RekomendasiModel.fromJson(Map<String, dynamic> json) {
int parseInt(dynamic v) {
@ -157,6 +189,10 @@ class RekomendasiModel {
lemak: parseInt(json['lemak']),
karbohidrat: parseInt(json['karbohidrat']),
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.tanggalLahir,
required this.jenisKelamin,
this.photoUrl,
this.latestStatus,
this.latestMeasurementAt,
});
@ -13,7 +12,6 @@ class MobileChildModel {
final String nama;
final String tanggalLahir;
final String jenisKelamin;
final String? photoUrl;
final String? latestStatus;
final String? latestMeasurementAt;
@ -23,10 +21,6 @@ class MobileChildModel {
nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] 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?,
latestMeasurementAt: json['latest_measurement_at'] as String?,
);
@ -37,7 +31,6 @@ class MobileChildModel {
String? nama,
String? tanggalLahir,
String? jenisKelamin,
String? photoUrl,
String? latestStatus,
String? latestMeasurementAt,
}) {
@ -46,7 +39,6 @@ class MobileChildModel {
nama: nama ?? this.nama,
tanggalLahir: tanggalLahir ?? this.tanggalLahir,
jenisKelamin: jenisKelamin ?? this.jenisKelamin,
photoUrl: photoUrl ?? this.photoUrl,
latestStatus: latestStatus ?? this.latestStatus,
latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt,
);

View File

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

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['status'] as String? ??
'-',
primaryCategory: normalized['primary_category'] as String? ?? 'Normal',
primaryCategory: normalized['primary_category'] as String? ?? 'Gizi Baik',
matchedCategories: rawCategories is List
? rawCategories.whereType<String>().toList()
: const ['Normal'],
: const ['Gizi Baik'],
items: (json['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(RekomendasiModel.fromJson)

View File

@ -12,6 +12,13 @@ class RiwayatResponseModel {
.map(RiwayatItemModel.fromJson)
.toList()
: <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(
child: ChildInfoModel.fromJson(
@ -28,14 +35,12 @@ class ChildInfoModel {
required this.nama,
required this.tanggalLahir,
required this.jenisKelamin,
this.photoUrl,
});
final int id;
final String nama;
final String tanggalLahir;
final String jenisKelamin;
final String? photoUrl;
factory ChildInfoModel.fromJson(Map<String, dynamic> json) {
return ChildInfoModel(
@ -43,10 +48,6 @@ class ChildInfoModel {
nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] 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.zTbu,
required this.zBbtb,
this.isAnomaly = false,
this.dataStatus = 'normal',
});
final int id;
@ -97,6 +100,8 @@ class RiwayatItemModel {
final double? zBbu;
final double? zTbu;
final double? zBbtb;
final bool isAnomaly;
final String dataStatus;
factory RiwayatItemModel.fromJson(Map<String, dynamic> json) {
final z = json['z_score'] as Map<String, dynamic>? ?? const {};
@ -115,6 +120,8 @@ class RiwayatItemModel {
zBbu: parseNullableNum(z['bbu']),
zTbu: parseNullableNum(z['tbu']),
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() {
super.initState();
_selectedExistingChildId = _appState.activeChildId;
_nameController.addListener(_refreshAvatar);
_shakeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
@ -52,12 +53,17 @@ class _AddChildScreenState extends State<AddChildScreen>
@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 &&
@ -110,9 +116,9 @@ class _AddChildScreenState extends State<AddChildScreen>
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: const Color(0xFF0B7A86),
),
colorScheme: Theme.of(
context,
).colorScheme.copyWith(primary: const Color(0xFF0B7A86)),
),
child: child!,
);
@ -180,187 +186,145 @@ class _AddChildScreenState extends State<AddChildScreen>
child: child,
);
},
child: HealthCard(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Detail Data Anak', style: AppTypography.h2),
const SizedBox(height: 4),
const Text(
'Lengkapi informasi untuk analisis gizi tepat.',
style: AppTypography.body,
),
const SizedBox(height: 18),
Center(
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: const Color(0xFFEFF9F8),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF0B7A86),
width: 2.2,
child:
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'),
),
),
child: ClipOval(
child: Padding(
padding: const EdgeInsets.all(18),
child: Image.asset(
'assets/image/logo_sgizi.png',
fit: BoxFit.contain,
),
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,
),
),
),
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,
),
),
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,
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
begin: const Offset(0.98, 0.98),
end: const Offset(1.02, 1.02),
duration: 2.seconds,
),
const SizedBox(height: 10),
Text(
'Unggah Foto Anak',
style: AppTypography.h3.copyWith(
color: const Color(0xFF0B7A86),
const SizedBox(height: 18),
_SaveButton(
loading: _loading,
onTap: _loading ? null : _save,
),
),
],
),
),
const SizedBox(height: 18),
_FieldLabel('NAMA LENGKAP'),
const SizedBox(height: 8),
TextField(
controller: _nameController,
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_showValidation) setState(() {});
},
decoration: _inputDecoration(
hint: 'Contoh: Arkan Syahputra',
icon: PhosphorIconsRegular.user,
showError:
_showValidation && _nameController.text.trim().isEmpty,
),
),
const SizedBox(height: 16),
_FieldLabel('JENIS KELAMIN'),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _GenderOption(
label: 'Laki-laki',
active: _gender == 'L',
onTap: () => setState(() => _gender = 'L'),
),
],
),
const SizedBox(width: 10),
Expanded(
child: _GenderOption(
label: 'Perempuan',
active: _gender == 'P',
onTap: () => setState(() => _gender = 'P'),
),
),
],
),
if (_showValidation && _gender == null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Pilih jenis kelamin.',
style: AppTypography.caption.copyWith(
color: SgColors.danger,
),
),
),
const SizedBox(height: 16),
_FieldLabel('TANGGAL LAHIR'),
const SizedBox(height: 8),
TextField(
controller: _dateController,
readOnly: true,
onTap: _pickDate,
decoration: _inputDecoration(
hint: 'Pilih tanggal lahir',
icon: LucideIcons.calendarDays,
showError: _showValidation && _birthDate == null,
),
),
const SizedBox(height: 10),
if (_birthDate != null)
Text(
'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!))}',
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: AppTypography.caption.copyWith(
color: SgColors.danger,
fontWeight: FontWeight.w700,
),
),
],
const SizedBox(height: 18),
_SaveButton(
loading: _loading,
onTap: _loading ? null : _save,
),
],
),
).animate().fadeIn(delay: 100.ms, duration: 320.ms).slideY(
begin: 0.1,
end: 0,
),
)
.animate()
.fadeIn(delay: 100.ms, duration: 320.ms)
.slideY(begin: 0.1, end: 0),
),
const SizedBox(height: 14),
HealthCard(
@ -472,7 +436,9 @@ class _GenderOption extends StatelessWidget {
height: 44,
alignment: Alignment.center,
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),
border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4),
@ -504,64 +470,67 @@ class _SaveButtonState extends State<_SaveButton> {
@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),
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),
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,
),
],
),
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,
);
)
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -2, duration: 1800.ms);
}
}
@ -583,7 +552,7 @@ class _ChildSelectorRow extends StatelessWidget {
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: children.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 10),
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
if (index == children.length) {
return Container(
@ -618,7 +587,9 @@ class _ChildSelectorRow extends StatelessWidget {
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2EAE7),
color: active
? const Color(0xFF0B7A86)
: const Color(0xFFE2EAE7),
width: active ? 1.8 : 1,
),
boxShadow: [
@ -636,7 +607,6 @@ class _ChildSelectorRow extends StatelessWidget {
ChildAvatar(
name: child.nama,
gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 22,
),
if (active)
@ -665,7 +635,10 @@ class _ChildSelectorRow extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
Text(
formatAgeFromBirthDate(child.tanggalLahir),
formatAgeFromBirthDate(
child.tanggalLahir,
source: 'add_child_existing_child_card',
),
style: AppTypography.caption,
maxLines: 1,
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 _heightController = TextEditingController();
String _jenisKelamin = 'L';
final String _jenisKelamin = 'L';
String _caraUkur = 'standing';
DateTime? _birthDate;
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),
),
],
);
},
),
),
);
}
}

File diff suppressed because it is too large Load Diff

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 '../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 {
const SplashScreen({super.key});
@ -25,20 +28,31 @@ class _SplashScreenState extends State<SplashScreen>
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),
);
_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), () {
if (!mounted) return;
Navigator.of(context).pushReplacement(fadeRoute(const OnboardingScreen()));
});
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) {
next = state.role == 'nutritionist'
? const NutritionistDashboardScreen()
: const ParentDashboardScreen();
}
Navigator.of(context).pushReplacement(fadeRoute(next));
}
@override
@ -87,7 +101,9 @@ class _SplashScreenState extends State<SplashScreen>
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: const Color(0xFF4B8E96).withValues(alpha: 0.24),
color: const Color(
0xFF4B8E96,
).withValues(alpha: 0.24),
blurRadius: 42,
spreadRadius: 4,
),
@ -99,7 +115,7 @@ class _SplashScreenState extends State<SplashScreen>
'assets/image/Logo_SplashScreen.png',
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorBuilder: (_, __, ___) => Image.asset(
errorBuilder: (_, _, _) => Image.asset(
'assets/image/logo_sgizi.png',
fit: BoxFit.contain,
),

View File

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../app_state.dart';
import '../models/api_result_model.dart';
import '../models/mobile_child_model.dart';
import '../models/news_article_model.dart';
import '../models/recommendation_response_model.dart';
import '../models/riwayat_response_model.dart';
import 'package:s_gizi/app_state.dart';
import 'package:s_gizi/models/api_result_model.dart';
import 'package:s_gizi/models/mobile_child_model.dart';
import 'package:s_gizi/models/news_article_model.dart';
import 'package:s_gizi/models/recommendation_response_model.dart';
import 'package:s_gizi/models/riwayat_response_model.dart';
class ApiService {
ApiService({http.Client? client, String? baseUrl})
@ -19,15 +20,16 @@ class ApiService {
final String baseUrl;
static String _resolveDefaultBaseUrl() {
// Web -> localhost browser, Android emulator -> 10.0.2.2
if (kIsWeb) {
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
}
const configured = String.fromEnvironment('SGIZI_API_BASE_URL');
if (configured.isNotEmpty) return configured;
// 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 {
final response = await _client.post(
@ -47,9 +49,121 @@ class ApiService {
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(
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 {
'Content-Type': 'application/json',
'Accept': 'application/json',
@ -58,13 +172,62 @@ class ApiService {
);
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(
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 {
'Content-Type': 'application/json',
'Accept': 'application/json',
@ -72,24 +235,21 @@ class ApiService {
body: jsonEncode({'phone': phone, 'otp': otp}),
);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
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;
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(_messageFromBody(response.body, 'Gagal verifikasi OTP.'));
}
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 {
final response = await _client.get(
Uri.parse('$baseUrl/children'),
headers: _headers(),
);
final response = await _client
.get(Uri.parse('$baseUrl/children'), headers: _headers())
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil data anak: ${response.body}');
@ -138,8 +298,10 @@ class ApiService {
body: jsonEncode({
if (data['nama'] != null) 'nama': data['nama'],
if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'],
if (data['tanggal_lahir'] != null) 'tanggal_lahir': data['tanggal_lahir'],
if (data['jenis_kelamin'] != null) 'jenis_kelamin': data['jenis_kelamin'],
if (data['tanggal_lahir'] != null)
'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}');
}
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.');
}
return MobileChildModel.fromJson(decoded['data'] as Map<String, dynamic>);
@ -169,10 +332,9 @@ class ApiService {
}
Future<RiwayatResponseModel> getRiwayat({required int childId}) async {
final response = await _client.get(
Uri.parse('$baseUrl/riwayat/$childId'),
headers: _headers(),
);
final response = await _client
.get(Uri.parse('$baseUrl/riwayat/$childId'), headers: _headers())
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
@ -193,17 +355,16 @@ class ApiService {
int? childId,
int? riwayatId,
}) 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(
queryParameters: {
if (childId != null) 'child_id': '$childId',
if (riwayatId != null) 'riwayat_id': '$riwayatId',
},
);
final response = await _client.get(
uri,
headers: _headers(),
);
final response = await _client.get(uri, headers: _headers());
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
@ -224,7 +385,9 @@ class ApiService {
}) async {
// Online/Internet (Google News RSS) tetap lewat endpoint `/news`
// 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());
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
@ -244,15 +407,15 @@ class ApiService {
}
Future<Map<String, dynamic>> getProfile() async {
final response = await _client.get(
Uri.parse('$baseUrl/profile'),
headers: _headers(),
);
final response = await _client
.get(Uri.parse('$baseUrl/profile'), headers: _headers())
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil profil: ${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.');
}
return decoded['data'] as Map<String, dynamic>;
@ -262,6 +425,11 @@ class ApiService {
required String name,
required String phone,
String? email,
String? gender,
String? birthDate,
String? specialization,
String? experience,
String? strSip,
}) async {
final response = await _client.put(
Uri.parse('$baseUrl/profile'),
@ -270,18 +438,70 @@ class ApiService {
'name': name,
'phone': phone,
'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) {
throw Exception('Gagal memperbarui profil: ${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.');
}
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).
Future<List<NewsArticleModel>> getArticlesDb() async {
final uri = Uri.parse('$baseUrl/articles');
@ -306,8 +526,9 @@ class ApiService {
Future<List<Map<String, dynamic>>> getConsultationRooms({
required int childId,
}) async {
final uri = Uri.parse('$baseUrl/consultation/rooms')
.replace(queryParameters: {'child_id': '$childId'});
final uri = Uri.parse(
'$baseUrl/consultation/rooms',
).replace(queryParameters: {'child_id': '$childId'});
final response = await _client.get(uri, headers: _headers());
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal mengambil room konsultasi: ${response.body}');
@ -318,6 +539,21 @@ class ApiService {
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({
required int childId,
required String expertId,
@ -342,7 +578,8 @@ class ApiService {
throw Exception('Gagal membuka room konsultasi: ${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.');
}
return decoded['data'] as Map<String, dynamic>;
@ -367,20 +604,34 @@ class ApiService {
Future<Map<String, dynamic>> sendConsultationMessage({
required int roomId,
required String message,
int? measurementId,
}) async {
final body = <String, dynamic>{'message': message};
if (measurementId != null) {
body['measurement_id'] = measurementId;
}
final response = await _client.post(
Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'),
headers: _headers(),
body: jsonEncode({'message': message}),
body: jsonEncode(body),
);
debugPrint(
'[sendConsultationMessage] status=${response.statusCode} body=${response.body}',
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Gagal kirim pesan konsultasi: ${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.');
}
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({
@ -396,12 +647,81 @@ class ApiService {
throw Exception('Gagal kirim balasan ahli: ${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.');
}
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({
required int roomId,
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() {
final token = SgiziAppState.instance.authToken;
return {

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