Push Akhir
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 22 KiB |
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) : '-';
|
||||
}
|
||||
|
|
@ -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(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}';
|
||||
}
|
||||
|
|
@ -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...'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||