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.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
# Legacy screens kept for reference during migration. Active app screens live
|
||||||
|
# under lib/features/** and lib/screens/nutritionist is superseded there.
|
||||||
|
- lib/screens/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
|
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application
|
<application
|
||||||
android:label="s_gizi"
|
android:label="s_gizi"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
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';
|
const String sgiziLogoAsset = 'assets/image/logo_sgizi.png';
|
||||||
|
|
||||||
|
/// Spacing ringkas untuk layout production-ready.
|
||||||
|
class SgSpacing {
|
||||||
|
const SgSpacing._();
|
||||||
|
|
||||||
|
static const pageH = 16.0;
|
||||||
|
static const pageV = 12.0;
|
||||||
|
static const section = 14.0;
|
||||||
|
static const item = 8.0;
|
||||||
|
static const cardPad = 12.0;
|
||||||
|
}
|
||||||
|
|
||||||
class SgColors {
|
class SgColors {
|
||||||
const SgColors._();
|
const SgColors._();
|
||||||
|
|
||||||
static const primary = Color(0xFF4B8E96);
|
static const primary = Color(0xFF4B8E96);
|
||||||
|
static const primaryTeal = Color(0xFF0B7A86);
|
||||||
static const primaryDark = Color(0xFF2F737A);
|
static const primaryDark = Color(0xFF2F737A);
|
||||||
static const secondary = Color(0xFFA8D5BA);
|
static const secondary = Color(0xFFA8D5BA);
|
||||||
static const background = Color(0xFFF5F7F6);
|
static const background = Color(0xFFF5F7F6);
|
||||||
|
|
@ -71,7 +83,7 @@ class AppLogo extends StatelessWidget {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorBuilder: (_, _, _) => Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -97,42 +109,101 @@ class AppLogo extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SgAvatar extends StatelessWidget {
|
||||||
|
const SgAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
this.radius = 28,
|
||||||
|
this.gender,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? gender;
|
||||||
|
final double radius;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initial = getInitialName(name);
|
||||||
|
final colors = avatarGradientColors(name);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: colors,
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.last.withValues(alpha: 0.22),
|
||||||
|
blurRadius: radius * 0.55,
|
||||||
|
offset: Offset(0, radius * 0.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: radius * 0.82,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String getInitialName(String? name) {
|
||||||
|
final value = (name ?? '').trim();
|
||||||
|
if (value.isEmpty) return 'A';
|
||||||
|
final first = value.characters.first.toUpperCase();
|
||||||
|
return RegExp(r'[A-Z0-9]').hasMatch(first) ? first : 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Color> avatarGradientColors(String? seed) {
|
||||||
|
const palettes = [
|
||||||
|
[Color(0xFF4B8E96), Color(0xFF6FC7C8)],
|
||||||
|
[Color(0xFF5B8DEF), Color(0xFF8DB7FF)],
|
||||||
|
[Color(0xFF58B98B), Color(0xFF9DDFC1)],
|
||||||
|
[Color(0xFFE89B5B), Color(0xFFFFC08A)],
|
||||||
|
[Color(0xFF8B7AE6), Color(0xFFC2B7FF)],
|
||||||
|
[Color(0xFFE06F91), Color(0xFFFFA9BD)],
|
||||||
|
];
|
||||||
|
final text = (seed ?? '').trim();
|
||||||
|
if (text.isEmpty) return palettes.first;
|
||||||
|
final hash = text.codeUnits.fold<int>(
|
||||||
|
0,
|
||||||
|
(value, code) => (value + code) & 0x7fffffff,
|
||||||
|
);
|
||||||
|
return palettes[hash % palettes.length];
|
||||||
|
}
|
||||||
|
|
||||||
class ChildAvatar extends StatelessWidget {
|
class ChildAvatar extends StatelessWidget {
|
||||||
const ChildAvatar({
|
const ChildAvatar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.gender,
|
required this.gender,
|
||||||
this.photoUrl,
|
|
||||||
this.radius = 28,
|
this.radius = 28,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String gender;
|
final String gender;
|
||||||
final String? photoUrl;
|
|
||||||
final double radius;
|
final double radius;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final imageUrl = photoUrl?.trim();
|
return SgAvatar(
|
||||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
name: name,
|
||||||
return CircleAvatar(
|
gender: gender,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
backgroundColor: const Color(0xFFD9EEE7),
|
icon: Icons.child_care_rounded,
|
||||||
backgroundImage: NetworkImage(imageUrl),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isFemale = gender.toLowerCase().startsWith('p');
|
|
||||||
final accent = isFemale ? const Color(0xFFFF8FA3) : SgColors.primary;
|
|
||||||
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: radius,
|
|
||||||
backgroundColor: accent.withValues(alpha: 0.12),
|
|
||||||
child: Icon(
|
|
||||||
isFemale ? Icons.face_3_rounded : Icons.face_rounded,
|
|
||||||
color: accent,
|
|
||||||
size: radius,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,11 +212,12 @@ class HealthCard extends StatelessWidget {
|
||||||
const HealthCard({
|
const HealthCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.padding = const EdgeInsets.all(16),
|
this.padding = const EdgeInsets.all(SgSpacing.cardPad),
|
||||||
this.margin,
|
this.margin,
|
||||||
this.color = SgColors.surface,
|
this.color = SgColors.surface,
|
||||||
this.borderColor,
|
this.borderColor,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.dense = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
@ -154,9 +226,11 @@ class HealthCard extends StatelessWidget {
|
||||||
final Color color;
|
final Color color;
|
||||||
final Color? borderColor;
|
final Color? borderColor;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool dense;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final radius = dense ? 16.0 : 18.0;
|
||||||
final card = AnimatedContainer(
|
final card = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
|
|
@ -164,13 +238,13 @@ class HealthCard extends StatelessWidget {
|
||||||
padding: padding,
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
border: Border.all(color: borderColor ?? SgColors.border),
|
border: Border.all(color: borderColor ?? SgColors.border),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
color: Colors.black.withValues(alpha: dense ? 0.04 : 0.06),
|
||||||
blurRadius: 20,
|
blurRadius: dense ? 12 : 16,
|
||||||
offset: const Offset(0, 10),
|
offset: Offset(0, dense ? 6 : 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -184,7 +258,7 @@ class HealthCard extends StatelessWidget {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: card,
|
child: card,
|
||||||
),
|
),
|
||||||
|
|
@ -197,25 +271,33 @@ class StatusBadge extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
required this.text,
|
required this.text,
|
||||||
this.color = SgColors.success,
|
this.color = SgColors.success,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 8 : 10,
|
||||||
|
vertical: compact ? 4 : 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.16),
|
color: color.withValues(alpha: 0.14),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: color.withValues(alpha: 0.36)),
|
border: Border.all(color: color.withValues(alpha: 0.32)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTypography.caption.copyWith(
|
style: AppTypography.caption.copyWith(
|
||||||
color: color,
|
color: color,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: compact ? 11 : 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -298,37 +380,50 @@ class EmptyState extends StatelessWidget {
|
||||||
required this.message,
|
required this.message,
|
||||||
this.actionLabel,
|
this.actionLabel,
|
||||||
this.onAction,
|
this.onAction,
|
||||||
|
this.icon = Icons.inbox_outlined,
|
||||||
|
this.assetImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
final String? actionLabel;
|
final String? actionLabel;
|
||||||
final VoidCallback? onAction;
|
final VoidCallback? onAction;
|
||||||
|
final IconData icon;
|
||||||
|
final String? assetImage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(20),
|
||||||
child: HealthCard(
|
child: HealthCard(
|
||||||
|
dense: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
if (assetImage != null)
|
||||||
radius: 32,
|
ClipRRect(
|
||||||
backgroundColor: Color(0xFFE9F6F2),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Icon(Icons.inbox_outlined, color: SgColors.primary),
|
child: Image.asset(
|
||||||
|
assetImage!,
|
||||||
|
height: 88,
|
||||||
|
width: 88,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => _emptyIcon(icon),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
)
|
||||||
|
else
|
||||||
|
_emptyIcon(icon),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
|
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
style: AppTypography.body,
|
style: AppTypography.body.copyWith(fontSize: 13),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (actionLabel != null && onAction != null) ...[
|
if (actionLabel != null && onAction != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
PrimaryButton(label: actionLabel!, onPressed: onAction),
|
PrimaryButton(label: actionLabel!, onPressed: onAction),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -339,11 +434,29 @@ class EmptyState extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ErrorState extends StatelessWidget {
|
Widget _emptyIcon(IconData icon) {
|
||||||
const ErrorState({super.key, required this.message, required this.onRetry});
|
return CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: const Color(0xFFE9F6F2),
|
||||||
|
child: Icon(icon, color: SgColors.primary, size: 28),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorState extends StatelessWidget {
|
||||||
|
const ErrorState({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.onRetry,
|
||||||
|
this.title = 'Terjadi Kendala',
|
||||||
|
this.icon = Icons.wifi_off_rounded,
|
||||||
|
this.color = SgColors.danger,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
final VoidCallback onRetry;
|
final VoidCallback onRetry;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -354,13 +467,13 @@ class ErrorState extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 32,
|
radius: 32,
|
||||||
backgroundColor: Color(0xFFFFEBEE),
|
backgroundColor: color.withValues(alpha: 0.12),
|
||||||
child: Icon(Icons.wifi_off_rounded, color: SgColors.danger),
|
child: Icon(icon, color: color),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Terjadi Kendala', style: AppTypography.h2),
|
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
|
|
@ -476,8 +589,8 @@ PageRouteBuilder<T> fadeRoute<T>(Widget page) {
|
||||||
return PageRouteBuilder<T>(
|
return PageRouteBuilder<T>(
|
||||||
transitionDuration: const Duration(milliseconds: 260),
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 220),
|
reverseTransitionDuration: const Duration(milliseconds: 220),
|
||||||
pageBuilder: (_, __, ___) => page,
|
pageBuilder: (_, _, _) => page,
|
||||||
transitionsBuilder: (_, animation, __, child) {
|
transitionsBuilder: (_, animation, _, child) {
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'models/mobile_child_model.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:s_gizi/models/mobile_child_model.dart';
|
||||||
|
|
||||||
class SgiziAppState extends ChangeNotifier {
|
class SgiziAppState extends ChangeNotifier {
|
||||||
SgiziAppState._();
|
SgiziAppState._();
|
||||||
|
|
@ -8,9 +11,12 @@ class SgiziAppState extends ChangeNotifier {
|
||||||
static final SgiziAppState instance = SgiziAppState._();
|
static final SgiziAppState instance = SgiziAppState._();
|
||||||
|
|
||||||
String? authToken;
|
String? authToken;
|
||||||
|
String? role;
|
||||||
int? activeChildId;
|
int? activeChildId;
|
||||||
|
bool showFamilyOverviewOnHome = true;
|
||||||
List<MobileChildModel> children = const [];
|
List<MobileChildModel> children = const [];
|
||||||
Map<String, dynamic>? profileData;
|
Map<String, dynamic>? profileData;
|
||||||
|
Map<String, dynamic>? userData;
|
||||||
|
|
||||||
bool get isAuthenticated => authToken != null && authToken!.isNotEmpty;
|
bool get isAuthenticated => authToken != null && authToken!.isNotEmpty;
|
||||||
MobileChildModel? get activeChild {
|
MobileChildModel? get activeChild {
|
||||||
|
|
@ -27,22 +33,81 @@ class SgiziAppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> restoreSession() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString(_tokenKey);
|
||||||
|
final storedRole = prefs.getString(_roleKey);
|
||||||
|
final userJson = prefs.getString(_userKey);
|
||||||
|
|
||||||
|
authToken = token;
|
||||||
|
role = storedRole;
|
||||||
|
if (userJson != null && userJson.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(userJson);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
userData = decoded;
|
||||||
|
profileData = decoded;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
userData = null;
|
||||||
|
profileData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveSession({
|
||||||
|
required String token,
|
||||||
|
required String role,
|
||||||
|
required Map<String, dynamic> user,
|
||||||
|
}) async {
|
||||||
|
authToken = token;
|
||||||
|
this.role = role;
|
||||||
|
userData = user;
|
||||||
|
profileData = user;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, token);
|
||||||
|
await prefs.setString(_roleKey, role);
|
||||||
|
await prefs.setString(_userKey, jsonEncode(user));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void setChildren(List<MobileChildModel> value) {
|
void setChildren(List<MobileChildModel> value) {
|
||||||
children = value;
|
children = value;
|
||||||
activeChildId ??= value.isNotEmpty ? value.first.id : null;
|
if (value.isEmpty) {
|
||||||
if (value.every((child) => child.id != activeChildId)) {
|
activeChildId = null;
|
||||||
activeChildId = value.isNotEmpty ? value.first.id : null;
|
showFamilyOverviewOnHome = true;
|
||||||
|
} else if (value.length == 1) {
|
||||||
|
activeChildId = value.first.id;
|
||||||
|
showFamilyOverviewOnHome = false;
|
||||||
|
} else if (value.every((child) => child.id != activeChildId)) {
|
||||||
|
activeChildId = null;
|
||||||
|
showFamilyOverviewOnHome = true;
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setProfileData(Map<String, dynamic> value) {
|
void setProfileData(Map<String, dynamic> value) {
|
||||||
profileData = value;
|
profileData = value;
|
||||||
|
userData = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setActiveChild(int id) {
|
void setActiveChild(int id) {
|
||||||
activeChildId = id;
|
activeChildId = id;
|
||||||
|
showFamilyOverviewOnHome = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void showFamilyOverview() {
|
||||||
|
showFamilyOverviewOnHome = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetActiveChild() {
|
||||||
|
activeChildId = null;
|
||||||
|
showFamilyOverviewOnHome = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,11 +129,22 @@ class SgiziAppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void logout() {
|
Future<void> logout() async {
|
||||||
authToken = null;
|
authToken = null;
|
||||||
|
role = null;
|
||||||
activeChildId = null;
|
activeChildId = null;
|
||||||
|
showFamilyOverviewOnHome = true;
|
||||||
children = const [];
|
children = const [];
|
||||||
profileData = null;
|
profileData = null;
|
||||||
|
userData = null;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_roleKey);
|
||||||
|
await prefs.remove(_userKey);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _tokenKey = 'sgizi_auth_token';
|
||||||
|
const _roleKey = 'sgizi_user_role';
|
||||||
|
const _userKey = 'sgizi_user_data';
|
||||||
|
|
|
||||||
|
|
@ -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:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import 'auth_screen.dart';
|
import 'package:s_gizi/features/auth/screens/auth_screen.dart';
|
||||||
|
|
||||||
class OnboardingScreen extends StatefulWidget {
|
class OnboardingScreen extends StatefulWidget {
|
||||||
const OnboardingScreen({super.key});
|
const OnboardingScreen({super.key});
|
||||||
|
|
@ -52,7 +52,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||||
'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.',
|
'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.',
|
||||||
floatingTags: [
|
floatingTags: [
|
||||||
_FloatingTag(
|
_FloatingTag(
|
||||||
label: 'Status Normal',
|
label: 'Status Gizi Baik',
|
||||||
icon: BootstrapIcons.heart,
|
icon: BootstrapIcons.heart,
|
||||||
align: Alignment(0.92, -0.48),
|
align: Alignment(0.92, -0.48),
|
||||||
),
|
),
|
||||||
|
|
@ -154,7 +154,10 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: i == _index
|
gradient: i == _index
|
||||||
? const LinearGradient(
|
? const LinearGradient(
|
||||||
colors: [SgColors.primaryDark, SgColors.primary],
|
colors: [
|
||||||
|
SgColors.primaryDark,
|
||||||
|
SgColors.primary,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
color: i == _index ? null : const Color(0xFFCAE8E2),
|
color: i == _index ? null : const Color(0xFFCAE8E2),
|
||||||
|
|
@ -232,10 +235,7 @@ class _OnboardingPage extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_HeroImageWithBadges(
|
_HeroImageWithBadges(imagePath: imagePath, floatingTags: const []),
|
||||||
imagePath: imagePath,
|
|
||||||
floatingTags: const [],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 34),
|
const SizedBox(height: 34),
|
||||||
Text.rich(
|
Text.rich(
|
||||||
_buildTitleSpan(),
|
_buildTitleSpan(),
|
||||||
|
|
@ -319,7 +319,7 @@ class _HeroImageWithBadges extends StatelessWidget {
|
||||||
imagePath,
|
imagePath,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorBuilder: (_, _, _) => Container(
|
||||||
color: const Color(0xFFE7F4F1),
|
color: const Color(0xFFE7F4F1),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
|
|
@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../app_state.dart';
|
import 'package:s_gizi/app_state.dart';
|
||||||
import 'add_child_screen.dart';
|
import 'package:s_gizi/features/children/screens/add_child_screen.dart';
|
||||||
|
|
||||||
class ChildrenScreen extends StatelessWidget {
|
class ChildrenScreen extends StatelessWidget {
|
||||||
const ChildrenScreen({super.key});
|
const ChildrenScreen({super.key});
|
||||||
|
|
@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../models/riwayat_response_model.dart';
|
import 'package:s_gizi/models/riwayat_response_model.dart';
|
||||||
import '../utils/nutrition_display_utils.dart';
|
import 'package:s_gizi/utils/nutrition_display_utils.dart';
|
||||||
import 'consultation_chat_screen.dart';
|
import 'package:s_gizi/features/consultation/screens/consultation_chat_screen.dart';
|
||||||
import 'recommendation_screen.dart';
|
import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
|
||||||
|
|
||||||
class RiwayatDetailScreen extends StatelessWidget {
|
class RiwayatDetailScreen extends StatelessWidget {
|
||||||
const RiwayatDetailScreen({
|
const RiwayatDetailScreen({
|
||||||
|
|
@ -66,7 +66,7 @@ class RiwayatDetailScreen extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Status Gizi: ${item.statusGabungan}',
|
'Status Gizi: ${localizeNutritionStatus(item.statusGabungan)}',
|
||||||
style: AppTypography.h2,
|
style: AppTypography.h2,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
@ -87,7 +87,6 @@ class RiwayatDetailScreen extends StatelessWidget {
|
||||||
ChildAvatar(
|
ChildAvatar(
|
||||||
name: child.nama,
|
name: child.nama,
|
||||||
gender: child.jenisKelamin,
|
gender: child.jenisKelamin,
|
||||||
photoUrl: child.photoUrl,
|
|
||||||
radius: 28,
|
radius: 28,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
|
|
@ -103,7 +102,11 @@ class RiwayatDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
formatAgeFromMonths(item.umurBulan),
|
formatAgeAtMeasurement(
|
||||||
|
birthDate: child.tanggalLahir,
|
||||||
|
measurementDate: item.tanggalUkur,
|
||||||
|
source: 'riwayat_detail_header',
|
||||||
|
),
|
||||||
style: AppTypography.caption,
|
style: AppTypography.caption,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
|
@ -124,8 +127,14 @@ class RiwayatDetailScreen extends StatelessWidget {
|
||||||
title: 'Data Pengukuran',
|
title: 'Data Pengukuran',
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_InfoRow(label: 'Berat', value: '${item.berat.toStringAsFixed(1)} kg'),
|
_InfoRow(
|
||||||
_InfoRow(label: 'Tinggi', value: '${item.tinggi.toStringAsFixed(1)} cm'),
|
label: 'Berat',
|
||||||
|
value: '${item.berat.toStringAsFixed(1)} kg',
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: 'Tinggi',
|
||||||
|
value: '${item.tinggi.toStringAsFixed(1)} cm',
|
||||||
|
),
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
label: 'Cara',
|
label: 'Cara',
|
||||||
value: measurementMethodLabel(item.caraUkur, item.umurBulan),
|
value: measurementMethodLabel(item.caraUkur, item.umurBulan),
|
||||||
|
|
@ -293,7 +302,7 @@ class _ScoreRow extends StatelessWidget {
|
||||||
SizedBox(width: 64, child: Text(label, style: AppTypography.body)),
|
SizedBox(width: 64, child: Text(label, style: AppTypography.body)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'$score -> $category',
|
'$score → ${localizeNutritionStatus(category)}',
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTypography.h3,
|
style: AppTypography.h3,
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,18 +5,19 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../app_state.dart';
|
import 'package:s_gizi/app_state.dart';
|
||||||
import '../models/api_result_model.dart';
|
import 'package:s_gizi/models/api_result_model.dart';
|
||||||
import '../models/mobile_child_model.dart';
|
import 'package:s_gizi/models/mobile_child_model.dart';
|
||||||
import '../models/news_article_model.dart';
|
import 'package:s_gizi/models/news_article_model.dart';
|
||||||
import '../models/riwayat_response_model.dart';
|
import 'package:s_gizi/models/riwayat_response_model.dart';
|
||||||
import '../services/api_service.dart';
|
import 'package:s_gizi/services/api_service.dart';
|
||||||
import '../utils/nutrition_display_utils.dart';
|
import 'package:s_gizi/services/article_service.dart';
|
||||||
import 'article_detail_screen.dart';
|
import 'package:s_gizi/utils/nutrition_display_utils.dart';
|
||||||
import 'articles_screen.dart';
|
import 'package:s_gizi/features/articles/screens/article_detail_screen.dart';
|
||||||
import 'recommendation_screen.dart';
|
import 'package:s_gizi/features/articles/screens/articles_screen.dart';
|
||||||
import 'riwayat_screen.dart';
|
import 'package:s_gizi/features/nutrition/screens/recommendation_screen.dart';
|
||||||
|
import 'package:s_gizi/features/history/screens/riwayat_screen.dart';
|
||||||
|
|
||||||
class NutritionScreen extends StatefulWidget {
|
class NutritionScreen extends StatefulWidget {
|
||||||
const NutritionScreen({super.key});
|
const NutritionScreen({super.key});
|
||||||
|
|
@ -27,6 +28,7 @@ class NutritionScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _NutritionScreenState extends State<NutritionScreen> {
|
class _NutritionScreenState extends State<NutritionScreen> {
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
|
final ArticleService _articleService = ArticleService();
|
||||||
final SgiziAppState _appState = SgiziAppState.instance;
|
final SgiziAppState _appState = SgiziAppState.instance;
|
||||||
late Future<_NutritionViewData> _future;
|
late Future<_NutritionViewData> _future;
|
||||||
String _activeCategory = 'Semua';
|
String _activeCategory = 'Semua';
|
||||||
|
|
@ -56,9 +58,14 @@ class _NutritionScreenState extends State<NutritionScreen> {
|
||||||
return const _NutritionViewData.noActiveChild();
|
return const _NutritionViewData.noActiveChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
final history = await _apiService.getRiwayat(childId: child.id);
|
final results = await Future.wait([
|
||||||
final articles = await _apiService.getArticlesDb();
|
_apiService.getRiwayat(childId: child.id),
|
||||||
final sorted = [...history.riwayat]..sort((a, b) {
|
_articleService.fetchArticles(forceRefresh: true),
|
||||||
|
]);
|
||||||
|
final history = results[0] as RiwayatResponseModel;
|
||||||
|
final articles = results[1] as List<NewsArticleModel>;
|
||||||
|
final sorted = [...history.riwayat]
|
||||||
|
..sort((a, b) {
|
||||||
final bDate = DateTime.tryParse(b.tanggalUkur);
|
final bDate = DateTime.tryParse(b.tanggalUkur);
|
||||||
final aDate = DateTime.tryParse(a.tanggalUkur);
|
final aDate = DateTime.tryParse(a.tanggalUkur);
|
||||||
if (aDate == null && bDate == null) return 0;
|
if (aDate == null && bDate == null) return 0;
|
||||||
|
|
@ -127,14 +134,27 @@ class _NutritionScreenState extends State<NutritionScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return RefreshIndicator(
|
||||||
physics: const BouncingScrollPhysics(),
|
color: SgColors.primary,
|
||||||
|
onRefresh: () async {
|
||||||
|
_retry();
|
||||||
|
await _future;
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(
|
||||||
|
parent: BouncingScrollPhysics(),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const _NutritionHeader(),
|
const _NutritionHeader(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
SgSpacing.pageH,
|
||||||
|
SgSpacing.section,
|
||||||
|
SgSpacing.pageH,
|
||||||
|
20,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -169,6 +189,7 @@ class _NutritionScreenState extends State<NutritionScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -210,9 +231,7 @@ class _NutritionHeader extends StatelessWidget {
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: BorderRadius.vertical(bottom: Radius.circular(32)),
|
||||||
bottom: Radius.circular(32),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -291,10 +310,7 @@ class _FloatingCircle extends StatelessWidget {
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -317,13 +333,14 @@ class _AgeGuideSection extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Panduan Usia',
|
'Panduan Usia',
|
||||||
style: AppTypography.h2.copyWith(
|
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
|
||||||
color: SgColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _NutriColors.secondary.withValues(alpha: 0.18),
|
color: _NutriColors.secondary.withValues(alpha: 0.18),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
|
@ -337,10 +354,7 @@ class _AgeGuideSection extends StatelessWidget {
|
||||||
color: _NutriColors.primary,
|
color: _NutriColors.primary,
|
||||||
),
|
),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text('0–5 Tahun', style: AppTypography.caption),
|
||||||
'0–5 Tahun',
|
|
||||||
style: AppTypography.caption,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -362,10 +376,7 @@ class _AgeGuideSection extends StatelessWidget {
|
||||||
itemBuilder: (context, index, realIndex) {
|
itemBuilder: (context, index, realIndex) {
|
||||||
final guide = _ageGuides[index];
|
final guide = _ageGuides[index];
|
||||||
final isActive = index == activeIndex;
|
final isActive = index == activeIndex;
|
||||||
return _AgeGuideCard(
|
return _AgeGuideCard(guide: guide, isActive: isActive);
|
||||||
guide: guide,
|
|
||||||
isActive: isActive,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
@ -424,7 +435,7 @@ class _CategoryChipsRow extends StatelessWidget {
|
||||||
onTap: () => onCategorySelected(label),
|
onTap: () => onCategorySelected(label),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||||
itemCount: _nutritionCategories.length,
|
itemCount: _nutritionCategories.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -457,8 +468,7 @@ class _ArticleSection extends StatelessWidget {
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final isAll = activeCategory == 'Semua';
|
final visible = articles.take(5).toList();
|
||||||
final visible = isAll ? articles.take(3).toList() : articles;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -473,9 +483,10 @@ class _ArticleSection extends StatelessWidget {
|
||||||
color: _NutriColors.primary,
|
color: _NutriColors.primary,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (isAll && articles.length > 3)
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: data.articles.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
fadeRoute(
|
fadeRoute(
|
||||||
ArticlesScreen(
|
ArticlesScreen(
|
||||||
|
|
@ -537,18 +548,18 @@ class _LatestStatusSection extends StatelessWidget {
|
||||||
'Belum ada riwayat pengukuran untuk anak aktif. Tambahkan pengukuran untuk menampilkan status terbaru.',
|
'Belum ada riwayat pengukuran untuk anak aktif. Tambahkan pengukuran untuk menampilkan status terbaru.',
|
||||||
actionLabel: 'Buka Riwayat',
|
actionLabel: 'Buka Riwayat',
|
||||||
onAction: () {
|
onAction: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
fadeRoute(RiwayatScreen(childId: data.child!.id)),
|
context,
|
||||||
);
|
).push(fadeRoute(RiwayatScreen(childId: data.child!.id)));
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_LatestNutritionStatusCard(
|
_LatestNutritionStatusCard(
|
||||||
child: data.child!,
|
child: data.child!,
|
||||||
latest: data.latestMeasurement!,
|
latest: data.latestMeasurement!,
|
||||||
onOpenHistory: () => Navigator.of(context).push(
|
onOpenHistory: () => Navigator.of(
|
||||||
fadeRoute(RiwayatScreen(childId: data.child!.id)),
|
context,
|
||||||
),
|
).push(fadeRoute(RiwayatScreen(childId: data.child!.id))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -634,7 +645,7 @@ class _FoodRecommendationSection extends StatelessWidget {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return _FoodPreviewCard(
|
return _FoodPreviewCard(
|
||||||
item: item,
|
item: item,
|
||||||
assetImage: _foodAssetByIndex(index),
|
fallbackAssetImage: _foodAssetByIndex(index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -714,50 +725,6 @@ class _CategoryChip extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EducationCard extends StatelessWidget {
|
|
||||||
const _EducationCard({required this.item});
|
|
||||||
|
|
||||||
final _EducationItem item;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return HealthCard(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 24,
|
|
||||||
backgroundColor: const Color(0xFFE9F6F2),
|
|
||||||
child: Icon(item.icon, color: SgColors.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
style: AppTypography.h3,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
item.description,
|
|
||||||
style: AppTypography.body,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AgeGuideCard extends StatelessWidget {
|
class _AgeGuideCard extends StatelessWidget {
|
||||||
const _AgeGuideCard({required this.guide, required this.isActive});
|
const _AgeGuideCard({required this.guide, required this.isActive});
|
||||||
|
|
||||||
|
|
@ -790,8 +757,10 @@ class _AgeGuideCard extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _NutriColors.secondary.withValues(alpha: 0.25),
|
color: _NutriColors.secondary.withValues(alpha: 0.25),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
|
@ -799,11 +768,7 @@ class _AgeGuideCard extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(guide.icon, color: _NutriColors.primary, size: 18),
|
||||||
guide.icon,
|
|
||||||
color: _NutriColors.primary,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
guide.title,
|
guide.title,
|
||||||
|
|
@ -837,11 +802,12 @@ class _AgeGuideCard extends StatelessWidget {
|
||||||
|
|
||||||
const List<String> _nutritionCategories = [
|
const List<String> _nutritionCategories = [
|
||||||
'Semua',
|
'Semua',
|
||||||
'Stunting',
|
'Gizi',
|
||||||
'MPASI',
|
'MPASI',
|
||||||
|
'Stunting',
|
||||||
|
'Tumbuh Kembang',
|
||||||
'Protein',
|
'Protein',
|
||||||
'Vitamin',
|
'Vitamin',
|
||||||
'Gizi Seimbang',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
class _LatestNutritionStatusCard extends StatelessWidget {
|
class _LatestNutritionStatusCard extends StatelessWidget {
|
||||||
|
|
@ -922,10 +888,13 @@ class _LatestNutritionStatusCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FoodPreviewCard extends StatelessWidget {
|
class _FoodPreviewCard extends StatelessWidget {
|
||||||
const _FoodPreviewCard({required this.item, required this.assetImage});
|
const _FoodPreviewCard({
|
||||||
|
required this.item,
|
||||||
|
required this.fallbackAssetImage,
|
||||||
|
});
|
||||||
|
|
||||||
final RekomendasiModel item;
|
final RekomendasiModel item;
|
||||||
final String assetImage;
|
final String fallbackAssetImage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -942,9 +911,9 @@ class _FoodPreviewCard extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.asset(
|
_FoodImage(
|
||||||
assetImage,
|
imageUrl: item.thumbnail,
|
||||||
fit: BoxFit.cover,
|
fallbackAssetImage: fallbackAssetImage,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12,
|
left: 12,
|
||||||
|
|
@ -1000,8 +969,33 @@ class _FoodPreviewCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FoodImage extends StatelessWidget {
|
||||||
|
const _FoodImage({required this.imageUrl, required this.fallbackAssetImage});
|
||||||
|
|
||||||
|
final String? imageUrl;
|
||||||
|
final String fallbackAssetImage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final url = _resolveMediaUrl(imageUrl);
|
||||||
|
if (url == null) {
|
||||||
|
return Image.asset(fallbackAssetImage, fit: BoxFit.cover);
|
||||||
|
}
|
||||||
|
return Image.network(
|
||||||
|
url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
Image.asset(fallbackAssetImage, fit: BoxFit.cover),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionEmptyInfo extends StatelessWidget {
|
class _SectionEmptyInfo extends StatelessWidget {
|
||||||
const _SectionEmptyInfo({required this.message, this.actionLabel, this.onAction});
|
const _SectionEmptyInfo({
|
||||||
|
required this.message,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
final String? actionLabel;
|
final String? actionLabel;
|
||||||
|
|
@ -1016,7 +1010,11 @@ class _SectionEmptyInfo extends StatelessWidget {
|
||||||
Text(message, style: AppTypography.body),
|
Text(message, style: AppTypography.body),
|
||||||
if (actionLabel != null && onAction != null) ...[
|
if (actionLabel != null && onAction != null) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
PrimaryButton(label: actionLabel!, onPressed: onAction, isOutlined: true),
|
PrimaryButton(
|
||||||
|
label: actionLabel!,
|
||||||
|
onPressed: onAction,
|
||||||
|
isOutlined: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1082,14 +1080,14 @@ class _NutritionSkeleton extends StatelessWidget {
|
||||||
height: 150,
|
height: 150,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemBuilder: (_, __) => Container(
|
itemBuilder: (context, index) => Container(
|
||||||
width: 260,
|
width: 260,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||||
itemCount: 3,
|
itemCount: 3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1113,10 +1111,7 @@ class _NutritionSkeleton extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArticleCard extends StatelessWidget {
|
class _ArticleCard extends StatelessWidget {
|
||||||
const _ArticleCard({
|
const _ArticleCard({required this.article, required this.allArticles});
|
||||||
required this.article,
|
|
||||||
required this.allArticles,
|
|
||||||
});
|
|
||||||
|
|
||||||
final NewsArticleModel article;
|
final NewsArticleModel article;
|
||||||
final List<NewsArticleModel> allArticles;
|
final List<NewsArticleModel> allArticles;
|
||||||
|
|
@ -1129,10 +1124,7 @@ class _ArticleCard extends StatelessWidget {
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
fadeRoute(
|
fadeRoute(
|
||||||
ArticleDetailScreen(
|
ArticleDetailScreen(article: article, related: allArticles),
|
||||||
article: article,
|
|
||||||
related: allArticles,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1142,14 +1134,15 @@ class _ArticleCard extends StatelessWidget {
|
||||||
Hero(
|
Hero(
|
||||||
tag: 'article-${article.id}',
|
tag: 'article-${article.id}',
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius:
|
borderRadius: const BorderRadius.horizontal(
|
||||||
const BorderRadius.horizontal(left: Radius.circular(20)),
|
left: Radius.circular(20),
|
||||||
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 104,
|
width: 104,
|
||||||
height: 96,
|
height: 96,
|
||||||
child: Image.asset(
|
child: _ArticleThumbImage(
|
||||||
_articleAssetByIndex(article.id),
|
imageUrl: article.image,
|
||||||
fit: BoxFit.cover,
|
fallbackAsset: _articleAssetByIndex(article.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1218,18 +1211,27 @@ class _ArticleCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EducationItem {
|
class _ArticleThumbImage extends StatelessWidget {
|
||||||
const _EducationItem({
|
const _ArticleThumbImage({
|
||||||
required this.title,
|
required this.imageUrl,
|
||||||
required this.description,
|
required this.fallbackAsset,
|
||||||
required this.icon,
|
|
||||||
required this.categories,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String? imageUrl;
|
||||||
final String description;
|
final String fallbackAsset;
|
||||||
final IconData icon;
|
|
||||||
final List<String> categories;
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final resolved = _resolveMediaUrl(imageUrl);
|
||||||
|
if (resolved == null) {
|
||||||
|
return Image.asset(fallbackAsset, fit: BoxFit.cover);
|
||||||
|
}
|
||||||
|
return Image.network(
|
||||||
|
resolved,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Image.asset(fallbackAsset, fit: BoxFit.cover),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AgeGuide {
|
class _AgeGuide {
|
||||||
|
|
@ -1248,37 +1250,6 @@ class _AgeGuide {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
const List<_EducationItem> _educationItems = [
|
|
||||||
_EducationItem(
|
|
||||||
title: 'Cegah Stunting Sejak Dini',
|
|
||||||
description:
|
|
||||||
'Pantau tinggi dan berat badan rutin serta penuhi protein hewani harian agar tumbuh kembang optimal.',
|
|
||||||
icon: Icons.height_rounded,
|
|
||||||
categories: ['Stunting', 'Protein', 'Gizi Seimbang'],
|
|
||||||
),
|
|
||||||
_EducationItem(
|
|
||||||
title: 'MPASI Bertahap dan Tepat Tekstur',
|
|
||||||
description:
|
|
||||||
'Mulai MPASI usia 6 bulan, tingkatkan tekstur bertahap, dan jaga frekuensi makan sesuai usia.',
|
|
||||||
icon: Icons.ramen_dining_rounded,
|
|
||||||
categories: ['MPASI', 'Gizi Seimbang'],
|
|
||||||
),
|
|
||||||
_EducationItem(
|
|
||||||
title: 'Protein untuk Pertumbuhan Tinggi Badan',
|
|
||||||
description:
|
|
||||||
'Protein hewani seperti telur, ikan, ayam, dan daging membantu pembentukan jaringan tubuh anak.',
|
|
||||||
icon: Icons.egg_alt_rounded,
|
|
||||||
categories: ['Protein', 'Stunting'],
|
|
||||||
),
|
|
||||||
_EducationItem(
|
|
||||||
title: 'Vitamin dan Mineral Esensial',
|
|
||||||
description:
|
|
||||||
'Lengkapi menu dengan sayur dan buah berwarna agar kebutuhan vitamin A, C, zat besi, dan zinc terpenuhi.',
|
|
||||||
icon: Icons.eco_rounded,
|
|
||||||
categories: ['Vitamin', 'Gizi Seimbang'],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const List<_AgeGuide> _ageGuides = [
|
const List<_AgeGuide> _ageGuides = [
|
||||||
_AgeGuide(
|
_AgeGuide(
|
||||||
title: 'Usia 0-6 Bulan',
|
title: 'Usia 0-6 Bulan',
|
||||||
|
|
@ -1324,15 +1295,6 @@ String _suggestionForStatus(String status) {
|
||||||
return 'Pertahankan pola makan gizi seimbang dan pemantauan berkala.';
|
return 'Pertahankan pola makan gizi seimbang dan pemantauan berkala.';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _imageForIndex(int index) {
|
|
||||||
const images = [
|
|
||||||
'https://images.unsplash.com/photo-1512058564366-18510be2db19?auto=format&fit=crop&w=900&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1490645935967-10de6ba17061?auto=format&fit=crop&w=900&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&w=900&q=80',
|
|
||||||
];
|
|
||||||
return images[index % images.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
TextStyle _sectionLabelStyle() {
|
TextStyle _sectionLabelStyle() {
|
||||||
return AppTypography.caption.copyWith(
|
return AppTypography.caption.copyWith(
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
|
|
@ -1367,3 +1329,18 @@ String _articleAssetByIndex(int index) {
|
||||||
];
|
];
|
||||||
return assets[index % assets.length];
|
return assets[index % assets.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _resolveMediaUrl(String? raw) {
|
||||||
|
final value = raw?.trim() ?? '';
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseUri = Uri.tryParse(ApiService().baseUrl);
|
||||||
|
if (baseUri == null) return value;
|
||||||
|
final root = baseUri.replace(path: '', query: '', fragment: '').toString();
|
||||||
|
final normalizedRoot = root.endsWith('/') ? root : '$root/';
|
||||||
|
final path = value.startsWith('/') ? value.substring(1) : value;
|
||||||
|
return '$normalizedRoot$path';
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../models/api_result_model.dart';
|
import 'package:s_gizi/models/api_result_model.dart';
|
||||||
import '../models/recommendation_response_model.dart';
|
import 'package:s_gizi/models/recommendation_response_model.dart';
|
||||||
import '../services/api_service.dart';
|
import 'package:s_gizi/services/api_service.dart';
|
||||||
import '../utils/nutrition_display_utils.dart';
|
import 'package:s_gizi/utils/nutrition_display_utils.dart';
|
||||||
|
import 'package:s_gizi/widgets/nutrition_status_badges.dart';
|
||||||
|
|
||||||
class RecommendationScreen extends StatefulWidget {
|
class RecommendationScreen extends StatefulWidget {
|
||||||
const RecommendationScreen({
|
const RecommendationScreen({
|
||||||
|
|
@ -93,8 +94,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = snapshot.data!;
|
final data = snapshot.data!;
|
||||||
final normalized = normalizeStatus(data.status ?? 'Normal');
|
final normalized = normalizeStatus(data.status ?? 'Gizi Baik');
|
||||||
final visual = nutritionStatusVisual(data.status ?? 'Normal');
|
|
||||||
|
|
||||||
if ((data.status == null || data.status!.isEmpty) &&
|
if ((data.status == null || data.status!.isEmpty) &&
|
||||||
data.items.isEmpty) {
|
data.items.isEmpty) {
|
||||||
|
|
@ -117,10 +117,23 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return RefreshIndicator(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 28),
|
color: SgColors.primary,
|
||||||
|
onRefresh: () async {
|
||||||
|
_retry();
|
||||||
|
await _future;
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
SgSpacing.pageH,
|
||||||
|
SgSpacing.pageV,
|
||||||
|
SgSpacing.pageH,
|
||||||
|
20,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
HealthCard(
|
HealthCard(
|
||||||
|
dense: true,
|
||||||
color: const Color(0xFFEAF7F7),
|
color: const Color(0xFFEAF7F7),
|
||||||
borderColor: const Color(0xFFCBEAEA),
|
borderColor: const Color(0xFFCBEAEA),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -134,32 +147,10 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
letterSpacing: 1.1,
|
letterSpacing: 1.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
Text(
|
NutritionStatusBadges(status: data.status ?? 'Gizi Baik'),
|
||||||
data.status ?? '-',
|
if (data.measuredAt != null &&
|
||||||
style: AppTypography.h2,
|
data.measuredAt!.isNotEmpty) ...[
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
StatusBadge(
|
|
||||||
text: normalized.primaryCategory,
|
|
||||||
color: visual.color,
|
|
||||||
),
|
|
||||||
...normalized.categories
|
|
||||||
.where((category) => category != normalized.primaryCategory)
|
|
||||||
.map(
|
|
||||||
(category) => StatusBadge(
|
|
||||||
text: category,
|
|
||||||
color: SgColors.primaryDark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (data.measuredAt != null && data.measuredAt!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Tanggal Pengukuran: ${formatMeasurementDate(data.measuredAt!)}',
|
'Tanggal Pengukuran: ${formatMeasurementDate(data.measuredAt!)}',
|
||||||
|
|
@ -168,7 +159,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (data.childName != null && data.childName!.isNotEmpty) ...[
|
if (data.childName != null &&
|
||||||
|
data.childName!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'Nama Anak: ${data.childName!}',
|
'Nama Anak: ${data.childName!}',
|
||||||
|
|
@ -179,17 +171,24 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
recommendationStatusExplanation(data.status ?? 'Normal'),
|
|
||||||
style: AppTypography.body,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(normalized.focusSummary, style: AppTypography.body),
|
Text(
|
||||||
|
friendlyDashboardSummary(data.status ?? 'Gizi Baik'),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTypography.body.copyWith(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
normalized.focusSummary,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTypography.caption,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'MENAMPILKAN ${data.items.length} MENU',
|
'MENAMPILKAN ${data.items.length} MENU',
|
||||||
style: AppTypography.caption.copyWith(
|
style: AppTypography.caption.copyWith(
|
||||||
|
|
@ -197,14 +196,20 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 12),
|
||||||
...data.items.asMap().entries.map(
|
...data.items.asMap().entries.map(
|
||||||
(entry) => _FoodCard(
|
(entry) => _FoodCard(
|
||||||
item: entry.value,
|
item: entry.value,
|
||||||
imageUrl: _imageForStatus(data.status ?? 'Normal', entry.key),
|
imageUrl: entry.value.thumbnail?.trim().isNotEmpty == true
|
||||||
|
? entry.value.thumbnail!.trim()
|
||||||
|
: _imageForStatus(
|
||||||
|
data.status ?? 'Gizi Baik',
|
||||||
|
entry.key,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -228,8 +233,7 @@ class _RecommendationViewData {
|
||||||
return _RecommendationViewData(
|
return _RecommendationViewData(
|
||||||
items: response.items,
|
items: response.items,
|
||||||
status: response.resolvedStatus,
|
status: response.resolvedStatus,
|
||||||
childName:
|
childName: (response.measurement?.childName.trim().isNotEmpty ?? false)
|
||||||
(response.measurement?.childName.trim().isNotEmpty ?? false)
|
|
||||||
? response.measurement?.childName
|
? response.measurement?.childName
|
||||||
: fallbackChildName,
|
: fallbackChildName,
|
||||||
measuredAt: response.measurement?.tanggalUkur ?? fallbackMeasuredAt,
|
measuredAt: response.measurement?.tanggalUkur ?? fallbackMeasuredAt,
|
||||||
|
|
@ -263,22 +267,23 @@ class _FoodCard extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return HealthCard(
|
return HealthCard(
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
dense: true,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 8,
|
aspectRatio: 16 / 6.5,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.network(
|
Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorBuilder: (_, _, _) => Container(
|
||||||
color: const Color(0xFFDDEFE8),
|
color: const Color(0xFFDDEFE8),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.restaurant_rounded,
|
Icons.restaurant_rounded,
|
||||||
|
|
@ -336,7 +341,7 @@ class _FoodCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -3,10 +3,11 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../app_state.dart';
|
import 'package:s_gizi/app_state.dart';
|
||||||
import '../services/api_service.dart';
|
import 'package:s_gizi/services/api_service.dart';
|
||||||
import 'edit_profile_screen.dart';
|
import 'package:s_gizi/utils/nutrition_display_utils.dart';
|
||||||
|
import 'package:s_gizi/features/profile/screens/edit_profile_screen.dart';
|
||||||
|
|
||||||
class AccountInfoScreen extends StatefulWidget {
|
class AccountInfoScreen extends StatefulWidget {
|
||||||
const AccountInfoScreen({super.key});
|
const AccountInfoScreen({super.key});
|
||||||
|
|
@ -58,11 +59,15 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
|
||||||
onRetry: _retry,
|
onRetry: _retry,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final profile = _profileCache ?? snapshot.data ?? const <String, dynamic>{};
|
final profile =
|
||||||
|
_profileCache ?? snapshot.data ?? const <String, dynamic>{};
|
||||||
final name = (profile['name'] as String? ?? '-').trim();
|
final name = (profile['name'] as String? ?? '-').trim();
|
||||||
final phone = (profile['phone'] as String? ?? '-').trim();
|
final phone = (profile['phone'] as String? ?? '-').trim();
|
||||||
final email = (profile['email'] as String? ?? '-').trim();
|
final email = (profile['email'] as String? ?? '-').trim();
|
||||||
final joinedAt = (profile['joined_at'] as String? ?? '-').trim();
|
final joinedAtRaw = (profile['joined_at'] as String? ?? '-').trim();
|
||||||
|
final joinedAt = joinedAtRaw == '-'
|
||||||
|
? '-'
|
||||||
|
: formatMeasurementDate(joinedAtRaw);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
|
|
@ -73,63 +78,40 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
|
||||||
HealthCard(
|
HealthCard(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
SgAvatar(name: name, radius: 39, icon: Icons.person)
|
||||||
clipBehavior: Clip.none,
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
children: [
|
.moveY(begin: 0, end: -2, duration: 1800.ms),
|
||||||
Container(
|
|
||||||
width: 78,
|
|
||||||
height: 78,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: const Color(0xFFEAF7F7),
|
|
||||||
border: Border.all(color: const Color(0xFF0B7A86), width: 1.8),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
child: ClipOval(
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/image/onboarding_consultation.png',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: 2,
|
|
||||||
bottom: 2,
|
|
||||||
child: Container(
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF34C759),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
|
|
||||||
begin: 0,
|
|
||||||
end: -2,
|
|
||||||
duration: 1800.ms,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(name, style: AppTypography.h2, maxLines: 1, overflow: TextOverflow.ellipsis),
|
Text(
|
||||||
|
name,
|
||||||
|
style: AppTypography.h2,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(phone, style: AppTypography.body, maxLines: 1, overflow: TextOverflow.ellipsis),
|
Text(
|
||||||
|
phone,
|
||||||
|
style: AppTypography.body,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const StatusBadge(text: 'Akun Terverifikasi', color: Color(0xFF0B7A86)),
|
const StatusBadge(
|
||||||
|
text: 'Akun Terverifikasi',
|
||||||
|
color: Color(0xFF0B7A86),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
|
final updated = await Navigator.of(context)
|
||||||
|
.push<Map<String, dynamic>>(
|
||||||
fadeRoute(
|
fadeRoute(
|
||||||
EditProfileScreen(
|
EditProfileScreen(
|
||||||
initialName: name,
|
initialName: name,
|
||||||
|
|
@ -146,7 +128,10 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
|
||||||
_state.setProfileData(updated);
|
_state.setProfileData(updated);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(LucideIcons.pencil, color: Color(0xFF0B7A86)),
|
icon: const Icon(
|
||||||
|
LucideIcons.pencil,
|
||||||
|
color: Color(0xFF0B7A86),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -154,7 +139,9 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Data Akun',
|
'Data Akun',
|
||||||
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
|
style: AppTypography.h2.copyWith(
|
||||||
|
color: SgColors.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
HealthCard(
|
HealthCard(
|
||||||
|
|
@ -189,10 +176,11 @@ class _AccountInfoScreenState extends State<AccountInfoScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: 'Edit Profile',
|
label: 'Edit Profil',
|
||||||
icon: LucideIcons.arrowRight,
|
icon: LucideIcons.arrowRight,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
|
final updated = await Navigator.of(context)
|
||||||
|
.push<Map<String, dynamic>>(
|
||||||
fadeRoute(
|
fadeRoute(
|
||||||
EditProfileScreen(
|
EditProfileScreen(
|
||||||
initialName: name,
|
initialName: name,
|
||||||
|
|
@ -280,13 +268,24 @@ class _AccountSkeleton extends StatelessWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(height: 120, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))),
|
Container(
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(height: 230, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))),
|
Container(
|
||||||
|
height: 230,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import '../app_state.dart';
|
import 'package:s_gizi/app_state.dart';
|
||||||
import '../services/api_service.dart';
|
import 'package:s_gizi/services/api_service.dart';
|
||||||
|
|
||||||
class EditProfileScreen extends StatefulWidget {
|
class EditProfileScreen extends StatefulWidget {
|
||||||
const EditProfileScreen({
|
const EditProfileScreen({
|
||||||
|
|
@ -37,16 +37,22 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
_nameController = TextEditingController(text: widget.initialName);
|
_nameController = TextEditingController(text: widget.initialName);
|
||||||
_phoneController = TextEditingController(text: widget.initialPhone);
|
_phoneController = TextEditingController(text: widget.initialPhone);
|
||||||
_emailController = TextEditingController(text: widget.initialEmail);
|
_emailController = TextEditingController(text: widget.initialEmail);
|
||||||
|
_nameController.addListener(_refreshAvatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_nameController.removeListener(_refreshAvatar);
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _refreshAvatar() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
|
|
@ -60,9 +66,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Gagal menyimpan: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Gagal menyimpan: $e')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -78,8 +84,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F7F6),
|
backgroundColor: const Color(0xFFF5F7F6),
|
||||||
|
resizeToAvoidBottomInset: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Edit Profile'),
|
title: const Text('Edit Profil'),
|
||||||
backgroundColor: const Color(0xFFF5F7F6),
|
backgroundColor: const Color(0xFFF5F7F6),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|
@ -95,62 +102,20 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
SgAvatar(
|
||||||
clipBehavior: Clip.none,
|
name: _nameController.text,
|
||||||
children: [
|
radius: 50,
|
||||||
Container(
|
icon: Icons.person_rounded,
|
||||||
width: 98,
|
)
|
||||||
height: 98,
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
decoration: BoxDecoration(
|
.scale(
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: const Color(0xFF0B7A86), width: 2),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
child: ClipOval(
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/image/onboarding_consultation.png',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: -2,
|
|
||||||
bottom: -2,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Upload foto akan tersedia di versi berikutnya.')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF0B7A86),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.12),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
|
|
||||||
begin: const Offset(0.99, 0.99),
|
begin: const Offset(0.99, 0.99),
|
||||||
end: const Offset(1.02, 1.02),
|
end: const Offset(1.02, 1.02),
|
||||||
duration: 1700.ms,
|
duration: 1700.ms,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'Ubah Foto Profile',
|
getInitialName(_nameController.text),
|
||||||
style: AppTypography.caption.copyWith(
|
style: AppTypography.caption.copyWith(
|
||||||
color: const Color(0xFF0B7A86),
|
color: const Color(0xFF0B7A86),
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -165,14 +130,18 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
label: 'Nama Lengkap',
|
label: 'Nama Lengkap',
|
||||||
icon: LucideIcons.user,
|
icon: LucideIcons.user,
|
||||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Nama wajib diisi.' : null,
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'Nama wajib diisi.'
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_InputField(
|
_InputField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
label: 'Nomor Telepon',
|
label: 'Nomor Telepon',
|
||||||
icon: LucideIcons.messageCircle,
|
icon: LucideIcons.messageCircle,
|
||||||
validator: (v) => (v == null || v.trim().length < 8) ? 'Nomor telepon tidak valid.' : null,
|
validator: (v) => (v == null || v.trim().length < 8)
|
||||||
|
? 'Nomor telepon tidak valid.'
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_InputField(
|
_InputField(
|
||||||
|
|
@ -182,7 +151,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
final value = (v ?? '').trim();
|
final value = (v ?? '').trim();
|
||||||
if (value.isEmpty) return null;
|
if (value.isEmpty) return null;
|
||||||
return value.contains('@') ? null : 'Format email tidak valid.';
|
return value.contains('@')
|
||||||
|
? null
|
||||||
|
: 'Format email tidak valid.';
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
@ -207,23 +178,34 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: _saving
|
child: _saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Simpan Perubahan',
|
'Simpan Perubahan',
|
||||||
style: AppTypography.h3.copyWith(color: Colors.white),
|
style: AppTypography.h3.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(LucideIcons.arrowRight, color: Colors.white, size: 18),
|
const Icon(
|
||||||
|
LucideIcons.arrowRight,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -276,4 +258,3 @@ class _InputField extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
|
|
||||||
class HelpScreen extends StatefulWidget {
|
class HelpScreen extends StatefulWidget {
|
||||||
const HelpScreen({super.key});
|
const HelpScreen({super.key});
|
||||||
|
|
@ -62,14 +62,16 @@ class _HelpScreenState extends State<HelpScreen> {
|
||||||
icon: LucideIcons.messageCircle,
|
icon: LucideIcons.messageCircle,
|
||||||
title: 'WhatsApp Admin',
|
title: 'WhatsApp Admin',
|
||||||
subtitle: '081249583765',
|
subtitle: '081249583765',
|
||||||
onTap: () => _launch(context, 'https://wa.me/6281249583765'),
|
onTap: () =>
|
||||||
|
_launch(context, 'https://wa.me/6281249583765'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ContactButton(
|
_ContactButton(
|
||||||
icon: LucideIcons.info,
|
icon: LucideIcons.info,
|
||||||
title: 'Email',
|
title: 'Email',
|
||||||
subtitle: 'smartgiziapp@gmail.com',
|
subtitle: 'smartgiziapp@gmail.com',
|
||||||
onTap: () => _launch(context, 'mailto:smartgiziapp@gmail.com'),
|
onTap: () =>
|
||||||
|
_launch(context, 'mailto:smartgiziapp@gmail.com'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ContactButton(
|
_ContactButton(
|
||||||
|
|
@ -133,7 +135,9 @@ class _FaqCardState extends State<_FaqCard> {
|
||||||
padding: const EdgeInsets.only(top: 10),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Text(widget.answer, style: AppTypography.body),
|
child: Text(widget.answer, style: AppTypography.body),
|
||||||
),
|
),
|
||||||
crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
crossFadeState: _expanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -210,4 +214,3 @@ Future<void> _launch(BuildContext context, String url) async {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app_design.dart';
|
import 'package:s_gizi/app_design.dart';
|
||||||
import 'services/local_notification_service.dart';
|
import 'package:s_gizi/services/local_notification_service.dart';
|
||||||
import 'screens/splash_screen.dart';
|
import 'package:s_gizi/features/auth/screens/splash_screen.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
await LocalNotificationService.instance.init();
|
await LocalNotificationService.instance.init();
|
||||||
|
|
||||||
runApp(const GiziApp());
|
runApp(const GiziApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ class AnalysisMeasurementModel {
|
||||||
required this.childName,
|
required this.childName,
|
||||||
required this.tanggalUkur,
|
required this.tanggalUkur,
|
||||||
required this.caraUkur,
|
required this.caraUkur,
|
||||||
|
this.isAnomaly = false,
|
||||||
|
this.dataStatus = 'normal',
|
||||||
|
this.validationStatus = 'valid',
|
||||||
|
this.validationNote = '',
|
||||||
|
this.monitoringStatus = 'normal',
|
||||||
|
this.isConfirmedByParent = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
|
|
@ -54,6 +60,12 @@ class AnalysisMeasurementModel {
|
||||||
final String childName;
|
final String childName;
|
||||||
final String tanggalUkur;
|
final String tanggalUkur;
|
||||||
final String? caraUkur;
|
final String? caraUkur;
|
||||||
|
final bool isAnomaly;
|
||||||
|
final String dataStatus;
|
||||||
|
final String validationStatus;
|
||||||
|
final String validationNote;
|
||||||
|
final String monitoringStatus;
|
||||||
|
final bool isConfirmedByParent;
|
||||||
|
|
||||||
factory AnalysisMeasurementModel.fromJson(Map<String, dynamic> json) {
|
factory AnalysisMeasurementModel.fromJson(Map<String, dynamic> json) {
|
||||||
return AnalysisMeasurementModel(
|
return AnalysisMeasurementModel(
|
||||||
|
|
@ -62,15 +74,30 @@ class AnalysisMeasurementModel {
|
||||||
childName: json['child_name'] as String? ?? '-',
|
childName: json['child_name'] as String? ?? '-',
|
||||||
tanggalUkur: json['tanggal_ukur'] as String? ?? '-',
|
tanggalUkur: json['tanggal_ukur'] as String? ?? '-',
|
||||||
caraUkur: json['cara_ukur'] as String?,
|
caraUkur: json['cara_ukur'] as String?,
|
||||||
|
isAnomaly: json['is_anomaly'] == true,
|
||||||
|
dataStatus: json['data_status'] as String? ?? 'normal',
|
||||||
|
validationStatus: json['validation_status'] as String? ?? 'valid',
|
||||||
|
validationNote: json['validation_note'] as String? ?? '',
|
||||||
|
monitoringStatus: json['monitoring_status'] as String? ?? 'normal',
|
||||||
|
isConfirmedByParent: json['is_confirmed_by_parent'] == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IdentitasModel {
|
class IdentitasModel {
|
||||||
const IdentitasModel({required this.umurBulan, required this.jenisKelamin});
|
const IdentitasModel({
|
||||||
|
required this.umurBulan,
|
||||||
|
required this.jenisKelamin,
|
||||||
|
required this.caraUkur,
|
||||||
|
required this.standarBbtb,
|
||||||
|
this.umurHari,
|
||||||
|
});
|
||||||
|
|
||||||
final double umurBulan;
|
final double umurBulan;
|
||||||
final String jenisKelamin;
|
final String jenisKelamin;
|
||||||
|
final String caraUkur;
|
||||||
|
final String standarBbtb;
|
||||||
|
final int? umurHari;
|
||||||
|
|
||||||
factory IdentitasModel.fromJson(Map<String, dynamic> json) {
|
factory IdentitasModel.fromJson(Map<String, dynamic> json) {
|
||||||
double parseNum(dynamic v) {
|
double parseNum(dynamic v) {
|
||||||
|
|
@ -81,6 +108,9 @@ class IdentitasModel {
|
||||||
return IdentitasModel(
|
return IdentitasModel(
|
||||||
umurBulan: parseNum(json['umur_bulan']),
|
umurBulan: parseNum(json['umur_bulan']),
|
||||||
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
||||||
|
caraUkur: json['cara_ukur'] as String? ?? '-',
|
||||||
|
standarBbtb: json['standar_bbtb'] as String? ?? 'BB/TB',
|
||||||
|
umurHari: (json['umur_hari'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +164,7 @@ class RekomendasiModel {
|
||||||
required this.lemak,
|
required this.lemak,
|
||||||
required this.karbohidrat,
|
required this.karbohidrat,
|
||||||
required this.alasan,
|
required this.alasan,
|
||||||
|
this.thumbnail,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String menu;
|
final String menu;
|
||||||
|
|
@ -142,6 +173,7 @@ class RekomendasiModel {
|
||||||
final int lemak;
|
final int lemak;
|
||||||
final int karbohidrat;
|
final int karbohidrat;
|
||||||
final String alasan;
|
final String alasan;
|
||||||
|
final String? thumbnail;
|
||||||
|
|
||||||
factory RekomendasiModel.fromJson(Map<String, dynamic> json) {
|
factory RekomendasiModel.fromJson(Map<String, dynamic> json) {
|
||||||
int parseInt(dynamic v) {
|
int parseInt(dynamic v) {
|
||||||
|
|
@ -157,6 +189,10 @@ class RekomendasiModel {
|
||||||
lemak: parseInt(json['lemak']),
|
lemak: parseInt(json['lemak']),
|
||||||
karbohidrat: parseInt(json['karbohidrat']),
|
karbohidrat: parseInt(json['karbohidrat']),
|
||||||
alasan: json['alasan'] as String? ?? '-',
|
alasan: json['alasan'] as String? ?? '-',
|
||||||
|
thumbnail:
|
||||||
|
json['thumbnail'] as String? ??
|
||||||
|
json['image'] as String? ??
|
||||||
|
json['image_url'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.nama,
|
||||||
required this.tanggalLahir,
|
required this.tanggalLahir,
|
||||||
required this.jenisKelamin,
|
required this.jenisKelamin,
|
||||||
this.photoUrl,
|
|
||||||
this.latestStatus,
|
this.latestStatus,
|
||||||
this.latestMeasurementAt,
|
this.latestMeasurementAt,
|
||||||
});
|
});
|
||||||
|
|
@ -13,7 +12,6 @@ class MobileChildModel {
|
||||||
final String nama;
|
final String nama;
|
||||||
final String tanggalLahir;
|
final String tanggalLahir;
|
||||||
final String jenisKelamin;
|
final String jenisKelamin;
|
||||||
final String? photoUrl;
|
|
||||||
final String? latestStatus;
|
final String? latestStatus;
|
||||||
final String? latestMeasurementAt;
|
final String? latestMeasurementAt;
|
||||||
|
|
||||||
|
|
@ -23,10 +21,6 @@ class MobileChildModel {
|
||||||
nama: json['nama'] as String? ?? '-',
|
nama: json['nama'] as String? ?? '-',
|
||||||
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
|
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
|
||||||
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
||||||
photoUrl:
|
|
||||||
json['photo_url'] as String? ??
|
|
||||||
json['foto_url'] as String? ??
|
|
||||||
json['avatar_url'] as String?,
|
|
||||||
latestStatus: json['latest_status'] as String?,
|
latestStatus: json['latest_status'] as String?,
|
||||||
latestMeasurementAt: json['latest_measurement_at'] as String?,
|
latestMeasurementAt: json['latest_measurement_at'] as String?,
|
||||||
);
|
);
|
||||||
|
|
@ -37,7 +31,6 @@ class MobileChildModel {
|
||||||
String? nama,
|
String? nama,
|
||||||
String? tanggalLahir,
|
String? tanggalLahir,
|
||||||
String? jenisKelamin,
|
String? jenisKelamin,
|
||||||
String? photoUrl,
|
|
||||||
String? latestStatus,
|
String? latestStatus,
|
||||||
String? latestMeasurementAt,
|
String? latestMeasurementAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -46,7 +39,6 @@ class MobileChildModel {
|
||||||
nama: nama ?? this.nama,
|
nama: nama ?? this.nama,
|
||||||
tanggalLahir: tanggalLahir ?? this.tanggalLahir,
|
tanggalLahir: tanggalLahir ?? this.tanggalLahir,
|
||||||
jenisKelamin: jenisKelamin ?? this.jenisKelamin,
|
jenisKelamin: jenisKelamin ?? this.jenisKelamin,
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
|
||||||
latestStatus: latestStatus ?? this.latestStatus,
|
latestStatus: latestStatus ?? this.latestStatus,
|
||||||
latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt,
|
latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,46 @@
|
||||||
class NewsArticleModel {
|
import 'article_model.dart';
|
||||||
const NewsArticleModel({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.description,
|
|
||||||
required this.content,
|
|
||||||
required this.category,
|
|
||||||
required this.createdAt,
|
|
||||||
this.sourceName,
|
|
||||||
this.image,
|
|
||||||
this.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int id;
|
class NewsArticleModel extends ArticleModel {
|
||||||
final String title;
|
const NewsArticleModel({
|
||||||
final String description;
|
required super.id,
|
||||||
final String content;
|
required super.title,
|
||||||
final String category;
|
required super.description,
|
||||||
final String createdAt;
|
required super.content,
|
||||||
final String? sourceName;
|
required super.category,
|
||||||
final String? image;
|
required String createdAt,
|
||||||
final String? url;
|
String? sourceName,
|
||||||
|
String? image,
|
||||||
|
String? url,
|
||||||
|
super.author,
|
||||||
|
}) : super(
|
||||||
|
publishedAt: createdAt,
|
||||||
|
source: sourceName,
|
||||||
|
imageUrl: image,
|
||||||
|
articleUrl: url,
|
||||||
|
);
|
||||||
|
|
||||||
|
String get createdAt => publishedAt;
|
||||||
|
String? get sourceName => source;
|
||||||
|
String? get image => imageUrl;
|
||||||
|
String? get url => articleUrl;
|
||||||
|
|
||||||
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final article = ArticleModel.fromJson(json);
|
||||||
|
return NewsArticleModel.fromArticle(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory NewsArticleModel.fromArticle(ArticleModel article) {
|
||||||
return NewsArticleModel(
|
return NewsArticleModel(
|
||||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
id: article.id,
|
||||||
title: json['title'] as String? ?? '-',
|
title: article.title,
|
||||||
description:
|
description: article.description,
|
||||||
(json['description'] as String? ?? json['excerpt'] as String? ?? '-')
|
content: article.content,
|
||||||
.trim(),
|
category: article.category,
|
||||||
content: (json['content'] as String? ?? '').trim(),
|
createdAt: article.publishedAt,
|
||||||
category: (json['category'] as String? ?? 'Nutrisi Anak').trim(),
|
sourceName: article.source,
|
||||||
createdAt:
|
image: article.imageUrl,
|
||||||
(json['created_at'] as String? ?? json['published_at'] as String? ?? '')
|
url: article.articleUrl,
|
||||||
.trim(),
|
author: article.author,
|
||||||
sourceName: (json['source_name'] as String? ?? '').trim().isEmpty
|
|
||||||
? null
|
|
||||||
: json['source_name'] as String?,
|
|
||||||
image: (json['image'] as String? ?? json['image_url'] as String? ?? '')
|
|
||||||
.trim()
|
|
||||||
.isEmpty
|
|
||||||
? null
|
|
||||||
: (json['image'] as String? ?? json['image_url'] as String?),
|
|
||||||
url: (json['url'] as String? ?? '').trim().isEmpty
|
|
||||||
? null
|
|
||||||
: json['url'] as String?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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['resolved_status'] as String? ??
|
||||||
json['status'] as String? ??
|
json['status'] as String? ??
|
||||||
'-',
|
'-',
|
||||||
primaryCategory: normalized['primary_category'] as String? ?? 'Normal',
|
primaryCategory: normalized['primary_category'] as String? ?? 'Gizi Baik',
|
||||||
matchedCategories: rawCategories is List
|
matchedCategories: rawCategories is List
|
||||||
? rawCategories.whereType<String>().toList()
|
? rawCategories.whereType<String>().toList()
|
||||||
: const ['Normal'],
|
: const ['Gizi Baik'],
|
||||||
items: (json['data'] as List<dynamic>? ?? const [])
|
items: (json['data'] as List<dynamic>? ?? const [])
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(RekomendasiModel.fromJson)
|
.map(RekomendasiModel.fromJson)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ class RiwayatResponseModel {
|
||||||
.map(RiwayatItemModel.fromJson)
|
.map(RiwayatItemModel.fromJson)
|
||||||
.toList()
|
.toList()
|
||||||
: <RiwayatItemModel>[];
|
: <RiwayatItemModel>[];
|
||||||
|
items.sort((a, b) {
|
||||||
|
final ad = DateTime.tryParse(a.tanggalUkur) ?? DateTime(1900);
|
||||||
|
final bd = DateTime.tryParse(b.tanggalUkur) ?? DateTime(1900);
|
||||||
|
final dateCompare = ad.compareTo(bd);
|
||||||
|
if (dateCompare != 0) return dateCompare;
|
||||||
|
return a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
return RiwayatResponseModel(
|
return RiwayatResponseModel(
|
||||||
child: ChildInfoModel.fromJson(
|
child: ChildInfoModel.fromJson(
|
||||||
|
|
@ -28,14 +35,12 @@ class ChildInfoModel {
|
||||||
required this.nama,
|
required this.nama,
|
||||||
required this.tanggalLahir,
|
required this.tanggalLahir,
|
||||||
required this.jenisKelamin,
|
required this.jenisKelamin,
|
||||||
this.photoUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final String nama;
|
final String nama;
|
||||||
final String tanggalLahir;
|
final String tanggalLahir;
|
||||||
final String jenisKelamin;
|
final String jenisKelamin;
|
||||||
final String? photoUrl;
|
|
||||||
|
|
||||||
factory ChildInfoModel.fromJson(Map<String, dynamic> json) {
|
factory ChildInfoModel.fromJson(Map<String, dynamic> json) {
|
||||||
return ChildInfoModel(
|
return ChildInfoModel(
|
||||||
|
|
@ -43,10 +48,6 @@ class ChildInfoModel {
|
||||||
nama: json['nama'] as String? ?? '-',
|
nama: json['nama'] as String? ?? '-',
|
||||||
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
|
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
|
||||||
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
|
||||||
photoUrl:
|
|
||||||
json['photo_url'] as String? ??
|
|
||||||
json['foto_url'] as String? ??
|
|
||||||
json['avatar_url'] as String?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +85,8 @@ class RiwayatItemModel {
|
||||||
required this.zBbu,
|
required this.zBbu,
|
||||||
required this.zTbu,
|
required this.zTbu,
|
||||||
required this.zBbtb,
|
required this.zBbtb,
|
||||||
|
this.isAnomaly = false,
|
||||||
|
this.dataStatus = 'normal',
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
|
|
@ -97,6 +100,8 @@ class RiwayatItemModel {
|
||||||
final double? zBbu;
|
final double? zBbu;
|
||||||
final double? zTbu;
|
final double? zTbu;
|
||||||
final double? zBbtb;
|
final double? zBbtb;
|
||||||
|
final bool isAnomaly;
|
||||||
|
final String dataStatus;
|
||||||
|
|
||||||
factory RiwayatItemModel.fromJson(Map<String, dynamic> json) {
|
factory RiwayatItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
final z = json['z_score'] as Map<String, dynamic>? ?? const {};
|
final z = json['z_score'] as Map<String, dynamic>? ?? const {};
|
||||||
|
|
@ -115,6 +120,8 @@ class RiwayatItemModel {
|
||||||
zBbu: parseNullableNum(z['bbu']),
|
zBbu: parseNullableNum(z['bbu']),
|
||||||
zTbu: parseNullableNum(z['tbu']),
|
zTbu: parseNullableNum(z['tbu']),
|
||||||
zBbtb: parseNullableNum(z['bbtb']),
|
zBbtb: parseNullableNum(z['bbtb']),
|
||||||
|
isAnomaly: json['is_anomaly'] == true,
|
||||||
|
dataStatus: json['data_status'] as String? ?? 'normal',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedExistingChildId = _appState.activeChildId;
|
_selectedExistingChildId = _appState.activeChildId;
|
||||||
|
_nameController.addListener(_refreshAvatar);
|
||||||
_shakeController = AnimationController(
|
_shakeController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 450),
|
duration: const Duration(milliseconds: 450),
|
||||||
|
|
@ -52,12 +53,17 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_nameController.removeListener(_refreshAvatar);
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_dateController.dispose();
|
_dateController.dispose();
|
||||||
_shakeController.dispose();
|
_shakeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _refreshAvatar() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
bool get _isValid {
|
bool get _isValid {
|
||||||
return _nameController.text.trim().isNotEmpty &&
|
return _nameController.text.trim().isNotEmpty &&
|
||||||
_birthDate != null &&
|
_birthDate != null &&
|
||||||
|
|
@ -110,9 +116,9 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
colorScheme: Theme.of(
|
||||||
primary: const Color(0xFF0B7A86),
|
context,
|
||||||
),
|
).colorScheme.copyWith(primary: const Color(0xFF0B7A86)),
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
);
|
);
|
||||||
|
|
@ -180,12 +186,16 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: HealthCard(
|
child:
|
||||||
|
HealthCard(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
|
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Detail Data Anak', style: AppTypography.h2),
|
Text(
|
||||||
|
'Detail Data Anak',
|
||||||
|
style: AppTypography.h2,
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'Lengkapi informasi untuk analisis gizi tepat.',
|
'Lengkapi informasi untuk analisis gizi tepat.',
|
||||||
|
|
@ -195,74 +205,24 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
Center(
|
Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
SgAvatar(
|
||||||
clipBehavior: Clip.none,
|
name: _nameController.text,
|
||||||
children: [
|
gender: _gender,
|
||||||
Container(
|
radius: 55,
|
||||||
width: 110,
|
icon: Icons.child_care_rounded,
|
||||||
height: 110,
|
)
|
||||||
decoration: BoxDecoration(
|
.animate(
|
||||||
color: const Color(0xFFEFF9F8),
|
onPlay: (c) =>
|
||||||
shape: BoxShape.circle,
|
c.repeat(reverse: true),
|
||||||
border: Border.all(
|
)
|
||||||
color: const Color(0xFF0B7A86),
|
.scale(
|
||||||
width: 2.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipOval(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/image/logo_sgizi.png',
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: -2,
|
|
||||||
bottom: -2,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Upload foto akan tersedia pada versi berikutnya.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF0B7A86),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.12),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.camera,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
|
|
||||||
begin: const Offset(0.98, 0.98),
|
begin: const Offset(0.98, 0.98),
|
||||||
end: const Offset(1.02, 1.02),
|
end: const Offset(1.02, 1.02),
|
||||||
duration: 2.seconds,
|
duration: 2.seconds,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'Unggah Foto Anak',
|
getInitialName(_nameController.text),
|
||||||
style: AppTypography.h3.copyWith(
|
style: AppTypography.h3.copyWith(
|
||||||
color: const Color(0xFF0B7A86),
|
color: const Color(0xFF0B7A86),
|
||||||
),
|
),
|
||||||
|
|
@ -283,7 +243,8 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
hint: 'Contoh: Arkan Syahputra',
|
hint: 'Contoh: Arkan Syahputra',
|
||||||
icon: PhosphorIconsRegular.user,
|
icon: PhosphorIconsRegular.user,
|
||||||
showError:
|
showError:
|
||||||
_showValidation && _nameController.text.trim().isEmpty,
|
_showValidation &&
|
||||||
|
_nameController.text.trim().isEmpty,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -295,7 +256,8 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
child: _GenderOption(
|
child: _GenderOption(
|
||||||
label: 'Laki-laki',
|
label: 'Laki-laki',
|
||||||
active: _gender == 'L',
|
active: _gender == 'L',
|
||||||
onTap: () => setState(() => _gender = 'L'),
|
onTap: () =>
|
||||||
|
setState(() => _gender = 'L'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|
@ -303,7 +265,8 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
child: _GenderOption(
|
child: _GenderOption(
|
||||||
label: 'Perempuan',
|
label: 'Perempuan',
|
||||||
active: _gender == 'P',
|
active: _gender == 'P',
|
||||||
onTap: () => setState(() => _gender = 'P'),
|
onTap: () =>
|
||||||
|
setState(() => _gender = 'P'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -328,13 +291,14 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
decoration: _inputDecoration(
|
decoration: _inputDecoration(
|
||||||
hint: 'Pilih tanggal lahir',
|
hint: 'Pilih tanggal lahir',
|
||||||
icon: LucideIcons.calendarDays,
|
icon: LucideIcons.calendarDays,
|
||||||
showError: _showValidation && _birthDate == null,
|
showError:
|
||||||
|
_showValidation && _birthDate == null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
if (_birthDate != null)
|
if (_birthDate != null)
|
||||||
Text(
|
Text(
|
||||||
'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!))}',
|
'Umur: ${formatAgeFromBirthDate(_toApiDate(_birthDate!), source: 'add_child_birthdate_preview')}',
|
||||||
style: AppTypography.caption.copyWith(
|
style: AppTypography.caption.copyWith(
|
||||||
color: const Color(0xFF0B7A86),
|
color: const Color(0xFF0B7A86),
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -357,10 +321,10 @@ class _AddChildScreenState extends State<AddChildScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
).animate().fadeIn(delay: 100.ms, duration: 320.ms).slideY(
|
)
|
||||||
begin: 0.1,
|
.animate()
|
||||||
end: 0,
|
.fadeIn(delay: 100.ms, duration: 320.ms)
|
||||||
),
|
.slideY(begin: 0.1, end: 0),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
HealthCard(
|
HealthCard(
|
||||||
|
|
@ -472,7 +436,9 @@ class _GenderOption extends StatelessWidget {
|
||||||
height: 44,
|
height: 44,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: active ? const Color(0xFF0B7A86).withValues(alpha: 0.10) : Colors.white,
|
color: active
|
||||||
|
? const Color(0xFF0B7A86).withValues(alpha: 0.10)
|
||||||
|
: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4),
|
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE0E7E4),
|
||||||
|
|
@ -546,10 +512,15 @@ class _SaveButtonState extends State<_SaveButton> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Simpan & Lanjutkan',
|
'Simpan & Lanjutkan',
|
||||||
style: AppTypography.h2.copyWith(color: Colors.white),
|
style: AppTypography.h2.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Icon(LucideIcons.arrowRight, color: Colors.white),
|
const Icon(
|
||||||
|
LucideIcons.arrowRight,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -557,11 +528,9 @@ class _SaveButtonState extends State<_SaveButton> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
|
)
|
||||||
begin: 0,
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
end: -2,
|
.moveY(begin: 0, end: -2, duration: 1800.ms);
|
||||||
duration: 1800.ms,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,7 +552,7 @@ class _ChildSelectorRow extends StatelessWidget {
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: children.length + 1,
|
itemCount: children.length + 1,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == children.length) {
|
if (index == children.length) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -618,7 +587,9 @@ class _ChildSelectorRow extends StatelessWidget {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2EAE7),
|
color: active
|
||||||
|
? const Color(0xFF0B7A86)
|
||||||
|
: const Color(0xFFE2EAE7),
|
||||||
width: active ? 1.8 : 1,
|
width: active ? 1.8 : 1,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
|
|
@ -636,7 +607,6 @@ class _ChildSelectorRow extends StatelessWidget {
|
||||||
ChildAvatar(
|
ChildAvatar(
|
||||||
name: child.nama,
|
name: child.nama,
|
||||||
gender: child.jenisKelamin,
|
gender: child.jenisKelamin,
|
||||||
photoUrl: child.photoUrl,
|
|
||||||
radius: 22,
|
radius: 22,
|
||||||
),
|
),
|
||||||
if (active)
|
if (active)
|
||||||
|
|
@ -665,7 +635,10 @@ class _ChildSelectorRow extends StatelessWidget {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
formatAgeFromBirthDate(child.tanggalLahir),
|
formatAgeFromBirthDate(
|
||||||
|
child.tanggalLahir,
|
||||||
|
source: 'add_child_existing_child_card',
|
||||||
|
),
|
||||||
style: AppTypography.caption,
|
style: AppTypography.caption,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -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 _weightController = TextEditingController();
|
||||||
final _heightController = TextEditingController();
|
final _heightController = TextEditingController();
|
||||||
|
|
||||||
String _jenisKelamin = 'L';
|
final String _jenisKelamin = 'L';
|
||||||
String _caraUkur = 'standing';
|
String _caraUkur = 'standing';
|
||||||
DateTime? _birthDate;
|
DateTime? _birthDate;
|
||||||
DateTime? _measurementDate;
|
DateTime? _measurementDate;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,12 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
|
||||||
import '../app_design.dart';
|
import '../app_design.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../models/mobile_child_model.dart';
|
import '../models/mobile_child_model.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../utils/nutrition_display_utils.dart';
|
import '../utils/nutrition_display_utils.dart';
|
||||||
|
import '../widgets/nutrition_status_badges.dart';
|
||||||
import 'add_child_screen.dart';
|
import 'add_child_screen.dart';
|
||||||
import 'account_info_screen.dart';
|
import 'account_info_screen.dart';
|
||||||
import 'auth_screen.dart';
|
import 'auth_screen.dart';
|
||||||
|
|
@ -51,10 +51,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
if (!_state.isAuthenticated) return;
|
if (!_state.isAuthenticated) return;
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([_api.getChildren(), _api.getProfile()]);
|
||||||
_api.getChildren(),
|
|
||||||
_api.getProfile(),
|
|
||||||
]);
|
|
||||||
final children = results[0] as List<MobileChildModel>;
|
final children = results[0] as List<MobileChildModel>;
|
||||||
final profile = results[1] as Map<String, dynamic>;
|
final profile = results[1] as Map<String, dynamic>;
|
||||||
_state.setChildren(children);
|
_state.setChildren(children);
|
||||||
|
|
@ -83,7 +80,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
final role = (profile?['role'] as String? ?? 'orang_tua').trim();
|
final role = (profile?['role'] as String? ?? 'orang_tua').trim();
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 28),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
SgSpacing.pageH,
|
||||||
|
SgSpacing.pageV,
|
||||||
|
SgSpacing.pageH,
|
||||||
|
20,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -91,9 +93,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
name: name.isEmpty ? 'Siti Aminah' : name,
|
name: name.isEmpty ? 'Siti Aminah' : name,
|
||||||
subtitle: _profileRoleToLabel(role, phone),
|
subtitle: _profileRoleToLabel(role, phone),
|
||||||
onEditTap: () async {
|
onEditTap: () async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(
|
||||||
fadeRoute(const AccountInfoScreen()),
|
context,
|
||||||
);
|
).push(fadeRoute(const AccountInfoScreen()));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_retry();
|
_retry();
|
||||||
},
|
},
|
||||||
|
|
@ -102,38 +104,40 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
_ChildrenSection(
|
_ChildrenSection(
|
||||||
children: _state.children,
|
children: _state.children,
|
||||||
activeChildId: _state.activeChildId,
|
activeChildId: _state.activeChildId,
|
||||||
onAdd: () => Navigator.of(context).push(
|
onAdd: () => Navigator.of(
|
||||||
fadeRoute(const AddChildScreen()),
|
context,
|
||||||
),
|
).push(fadeRoute(const AddChildScreen())),
|
||||||
onSelect: (id) => _state.setActiveChild(id),
|
onSelect: (id) => _state.setActiveChild(id),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Text(
|
Text(
|
||||||
'Pengaturan',
|
'Pengaturan',
|
||||||
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
|
style: AppTypography.h2.copyWith(
|
||||||
|
color: SgColors.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_SettingsCard(
|
_SettingsCard(
|
||||||
onTapAccount: () async {
|
onTapAccount: () async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(
|
||||||
fadeRoute(const AccountInfoScreen()),
|
context,
|
||||||
);
|
).push(fadeRoute(const AccountInfoScreen()));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_retry();
|
_retry();
|
||||||
},
|
},
|
||||||
onTapPrivacy: () => Navigator.of(context).push(
|
onTapPrivacy: () => Navigator.of(
|
||||||
fadeRoute(const SecurityScreen()),
|
context,
|
||||||
),
|
).push(fadeRoute(const SecurityScreen())),
|
||||||
onTapHelp: () => Navigator.of(context).push(
|
onTapHelp: () => Navigator.of(
|
||||||
fadeRoute(const HelpScreen()),
|
context,
|
||||||
),
|
).push(fadeRoute(const HelpScreen())),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_LogoutCard(
|
_LogoutCard(
|
||||||
onLogout: () async {
|
onLogout: () async {
|
||||||
final ok = await _confirmLogout(context);
|
final ok = await _confirmLogout(context);
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
_state.logout();
|
await _state.logout();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
fadeRoute(const AuthScreen()),
|
fadeRoute(const AuthScreen()),
|
||||||
|
|
@ -155,7 +159,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
)
|
)
|
||||||
.animate()
|
.animate()
|
||||||
.fadeIn(duration: 260.ms, curve: Curves.easeOut)
|
.fadeIn(duration: 260.ms, curve: Curves.easeOut)
|
||||||
.slideY(begin: 0.02, end: 0, duration: 260.ms, curve: Curves.easeOut);
|
.slideY(
|
||||||
|
begin: 0.02,
|
||||||
|
end: 0,
|
||||||
|
duration: 260.ms,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -236,14 +245,10 @@ class _ProfileHeaderCard extends StatelessWidget {
|
||||||
color: Colors.white.withValues(alpha: 0.22),
|
color: Colors.white.withValues(alpha: 0.22),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: SgAvatar(
|
||||||
child: Padding(
|
name: name,
|
||||||
padding: const EdgeInsets.all(10),
|
radius: 37,
|
||||||
child: Image.asset(
|
icon: Icons.person_rounded,
|
||||||
'assets/image/onboarding_consultation.png',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.animate(onPlay: (c) => c.repeat(reverse: true))
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
||||||
|
|
@ -322,10 +327,7 @@ class _ProfileHeaderCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
_IconPillButton(
|
_IconPillButton(icon: LucideIcons.pencil, onTap: onEditTap),
|
||||||
icon: LucideIcons.pencil,
|
|
||||||
onTap: onEditTap,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -388,7 +390,9 @@ class _ChildrenSection extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Daftar Anak',
|
'Daftar Anak',
|
||||||
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
|
style: AppTypography.h2.copyWith(
|
||||||
|
color: SgColors.textPrimary,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -431,12 +435,12 @@ class _ChildrenSection extends StatelessWidget {
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 152,
|
height: 88,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
itemCount: children.length,
|
itemCount: children.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final c = children[index];
|
final c = children[index];
|
||||||
final active = c.id == activeChildId;
|
final active = c.id == activeChildId;
|
||||||
|
|
@ -469,30 +473,28 @@ class _ChildCard extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = (child.latestStatus ?? '').trim().isEmpty
|
final statusRaw = (child.latestStatus ?? '').trim();
|
||||||
? 'Status: Belum ada'
|
|
||||||
: 'Status: ${child.latestStatus}';
|
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
width: 260,
|
width: 210,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2E8E6),
|
color: active ? const Color(0xFF0B7A86) : const Color(0xFFE2E8E6),
|
||||||
width: active ? 1.8 : 1,
|
width: active ? 1.6 : 1,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: active ? 0.10 : 0.06),
|
color: Colors.black.withValues(alpha: active ? 0.08 : 0.04),
|
||||||
blurRadius: active ? 26 : 18,
|
blurRadius: active ? 14 : 10,
|
||||||
offset: const Offset(0, 12),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -503,8 +505,7 @@ class _ChildCard extends StatelessWidget {
|
||||||
ChildAvatar(
|
ChildAvatar(
|
||||||
name: child.nama,
|
name: child.nama,
|
||||||
gender: child.jenisKelamin,
|
gender: child.jenisKelamin,
|
||||||
photoUrl: child.photoUrl,
|
radius: 20,
|
||||||
radius: 28,
|
|
||||||
),
|
),
|
||||||
if (active)
|
if (active)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
@ -522,48 +523,53 @@ class _ChildCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
child.nama,
|
child.nama,
|
||||||
style: AppTypography.h3.copyWith(color: SgColors.textPrimary),
|
style: AppTypography.h3.copyWith(
|
||||||
|
color: SgColors.textPrimary,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
formatAgeFromBirthDate(
|
||||||
|
child.tanggalLahir,
|
||||||
|
source: 'profile_child_card',
|
||||||
|
),
|
||||||
|
style: AppTypography.caption.copyWith(fontSize: 11),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
if (statusRaw.isEmpty)
|
||||||
Text(
|
Text(
|
||||||
'${child.jenisKelamin == 'P' ? 'Perempuan' : 'Laki-laki'} • ${formatAgeFromBirthDate(child.tanggalLahir)}',
|
'Belum diukur',
|
||||||
style: AppTypography.caption,
|
style: AppTypography.caption.copyWith(fontSize: 10),
|
||||||
maxLines: 1,
|
)
|
||||||
overflow: TextOverflow.ellipsis,
|
else
|
||||||
),
|
NutritionStatusBadges(
|
||||||
const SizedBox(height: 10),
|
status: statusRaw,
|
||||||
Container(
|
compact: true,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
spacing: 4,
|
||||||
decoration: BoxDecoration(
|
runSpacing: 4,
|
||||||
color: active
|
|
||||||
? const Color(0xFF7FD6C2).withValues(alpha: 0.22)
|
|
||||||
: const Color(0xFFF1F5F4),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
status,
|
|
||||||
style: AppTypography.caption.copyWith(
|
|
||||||
color: active ? const Color(0xFF085B63) : SgColors.textSecondary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
elevation: 10,
|
||||||
|
color: Colors.white,
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'edit') {
|
if (value == 'edit') {
|
||||||
await _showEditChildDialog(context, child);
|
await _showEditChildDialog(context, child);
|
||||||
|
|
@ -572,13 +578,47 @@ class _ChildCard extends StatelessWidget {
|
||||||
await _showDeleteChildDialog(context, child);
|
await _showDeleteChildDialog(context, child);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(value: 'edit', child: Text('Edit')),
|
const PopupMenuItem(
|
||||||
PopupMenuItem(value: 'delete', child: Text('Hapus')),
|
value: 'edit',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.edit3,
|
||||||
|
size: 18,
|
||||||
|
color: SgColors.primaryTeal,
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('Edit'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFFE25555),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Hapus',
|
||||||
|
style: AppTypography.body.copyWith(
|
||||||
|
color: const Color(0xFFE25555),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
LucideIcons.moreVertical,
|
LucideIcons.moreVertical,
|
||||||
color: active ? const Color(0xFF0B7A86) : SgColors.textSecondary,
|
color: active
|
||||||
|
? const Color(0xFF0B7A86)
|
||||||
|
: SgColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -681,10 +721,7 @@ class _SettingsTile extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(LucideIcons.chevronRight, color: SgColors.textSecondary),
|
||||||
LucideIcons.chevronRight,
|
|
||||||
color: SgColors.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -734,10 +771,7 @@ class _LogoutCard extends StatelessWidget {
|
||||||
color: const Color(0xFFE53935).withValues(alpha: 0.12),
|
color: const Color(0xFFE53935).withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(LucideIcons.logOut, color: Color(0xFFE53935)),
|
||||||
LucideIcons.logOut,
|
|
||||||
color: Color(0xFFE53935),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
|
|
@ -749,7 +783,9 @@ class _LogoutCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: onLogout,
|
onPressed: onLogout,
|
||||||
style: TextButton.styleFrom(foregroundColor: const Color(0xFFE53935)),
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFE53935),
|
||||||
|
),
|
||||||
child: const Text('Keluar'),
|
child: const Text('Keluar'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -830,7 +866,7 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
barrierLabel: 'logout',
|
barrierLabel: 'logout',
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierColor: Colors.black.withValues(alpha: 0.38),
|
barrierColor: Colors.black.withValues(alpha: 0.38),
|
||||||
pageBuilder: (context, _, __) {
|
pageBuilder: (context, _, _) {
|
||||||
return Center(
|
return Center(
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
|
@ -838,7 +874,8 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Container(
|
child:
|
||||||
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
|
|
@ -885,17 +922,24 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 52,
|
height: 52,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(false),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: SgColors.textPrimary,
|
foregroundColor: SgColors.textPrimary,
|
||||||
side: const BorderSide(color: SgColors.border),
|
side: const BorderSide(
|
||||||
|
color: SgColors.border,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(
|
||||||
|
16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Batal',
|
'Batal',
|
||||||
style: TextStyle(fontWeight: FontWeight.w700),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -905,16 +949,23 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 52,
|
height: 52,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFFE53935),
|
backgroundColor: const Color(
|
||||||
|
0xFFE53935,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(
|
||||||
|
16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Keluar',
|
'Keluar',
|
||||||
style: TextStyle(fontWeight: FontWeight.w800),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -923,7 +974,10 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
).animate().fadeIn(duration: 180.ms).scale(
|
)
|
||||||
|
.animate()
|
||||||
|
.fadeIn(duration: 180.ms)
|
||||||
|
.scale(
|
||||||
begin: const Offset(0.98, 0.98),
|
begin: const Offset(0.98, 0.98),
|
||||||
end: const Offset(1, 1),
|
end: const Offset(1, 1),
|
||||||
duration: 180.ms,
|
duration: 180.ms,
|
||||||
|
|
@ -944,12 +998,6 @@ Future<bool?> _confirmLogout(BuildContext context) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toast(BuildContext context, String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _profileRoleToLabel(String role, String fallbackPhone) {
|
String _profileRoleToLabel(String role, String fallbackPhone) {
|
||||||
final v = role.toLowerCase();
|
final v = role.toLowerCase();
|
||||||
if (v.contains('ortu') || v.contains('orang_tua') || v.contains('parent')) {
|
if (v.contains('ortu') || v.contains('orang_tua') || v.contains('parent')) {
|
||||||
|
|
@ -961,7 +1009,10 @@ String _profileRoleToLabel(String role, String fallbackPhone) {
|
||||||
return fallbackPhone.isEmpty ? 'Akun Aktif' : fallbackPhone;
|
return fallbackPhone.isEmpty ? 'Akun Aktif' : fallbackPhone;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child) async {
|
Future<void> _showEditChildDialog(
|
||||||
|
BuildContext context,
|
||||||
|
MobileChildModel child,
|
||||||
|
) async {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
final nameController = TextEditingController(text: child.nama);
|
final nameController = TextEditingController(text: child.nama);
|
||||||
String gender = child.jenisKelamin;
|
String gender = child.jenisKelamin;
|
||||||
|
|
@ -984,7 +1035,7 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: gender,
|
initialValue: gender,
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 'L', child: Text('Laki-laki')),
|
DropdownMenuItem(value: 'L', child: Text('Laki-laki')),
|
||||||
DropdownMenuItem(value: 'P', child: Text('Perempuan')),
|
DropdownMenuItem(value: 'P', child: Text('Perempuan')),
|
||||||
|
|
@ -993,7 +1044,9 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
|
||||||
if (v == null) return;
|
if (v == null) return;
|
||||||
setLocal(() => gender = v);
|
setLocal(() => gender = v);
|
||||||
},
|
},
|
||||||
decoration: const InputDecoration(labelText: 'Jenis Kelamin'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Jenis Kelamin',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|
@ -1038,7 +1091,9 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
|
||||||
);
|
);
|
||||||
final children = await api.getChildren();
|
final children = await api.getChildren();
|
||||||
SgiziAppState.instance.setChildren(children);
|
SgiziAppState.instance.setChildren(children);
|
||||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
if (dialogContext.mounted) {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!dialogContext.mounted) return;
|
if (!dialogContext.mounted) return;
|
||||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||||
|
|
@ -1056,7 +1111,10 @@ Future<void> _showEditChildDialog(BuildContext context, MobileChildModel child)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child) async {
|
Future<void> _showDeleteChildDialog(
|
||||||
|
BuildContext context,
|
||||||
|
MobileChildModel child,
|
||||||
|
) async {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -1070,7 +1128,9 @@ Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFE53935)),
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE53935),
|
||||||
|
),
|
||||||
child: const Text('Hapus'),
|
child: const Text('Hapus'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1084,8 +1144,8 @@ Future<void> _showDeleteChildDialog(BuildContext context, MobileChildModel child
|
||||||
SgiziAppState.instance.setChildren(children);
|
SgiziAppState.instance.setChildren(children);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Gagal hapus anak: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Gagal hapus anak: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app_design.dart';
|
import '../app_design.dart';
|
||||||
import 'onboarding_screen.dart';
|
import '../app_state.dart';
|
||||||
|
import '../features/auth/screens/onboarding_screen.dart';
|
||||||
|
import '../features/dashboard/screens/parent_dashboard_screen.dart';
|
||||||
|
import '../features/nutritionist/screens/nutritionist_dashboard_screen.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
@ -25,20 +28,31 @@ class _SplashScreenState extends State<SplashScreen>
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1200),
|
duration: const Duration(milliseconds: 1200),
|
||||||
)..forward();
|
)..forward();
|
||||||
_imageScale = Tween<double>(begin: 1.22, end: 1).animate(
|
_imageScale = Tween<double>(
|
||||||
CurvedAnimation(
|
begin: 1.22,
|
||||||
parent: _controller,
|
end: 1,
|
||||||
curve: Curves.easeOutCubic,
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||||
),
|
_imageOpacity = Tween<double>(
|
||||||
);
|
begin: 0.2,
|
||||||
_imageOpacity = Tween<double>(begin: 0.2, end: 1).animate(
|
end: 1,
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||||
);
|
|
||||||
|
|
||||||
Timer(const Duration(milliseconds: 1500), () {
|
Timer(const Duration(milliseconds: 1500), _openNextScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openNextScreen() async {
|
||||||
|
await SgiziAppState.instance.restoreSession();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(fadeRoute(const OnboardingScreen()));
|
|
||||||
});
|
final state = SgiziAppState.instance;
|
||||||
|
Widget next = const OnboardingScreen();
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
next = state.role == 'nutritionist'
|
||||||
|
? const NutritionistDashboardScreen()
|
||||||
|
: const ParentDashboardScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pushReplacement(fadeRoute(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -87,7 +101,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF4B8E96).withValues(alpha: 0.24),
|
color: const Color(
|
||||||
|
0xFF4B8E96,
|
||||||
|
).withValues(alpha: 0.24),
|
||||||
blurRadius: 42,
|
blurRadius: 42,
|
||||||
spreadRadius: 4,
|
spreadRadius: 4,
|
||||||
),
|
),
|
||||||
|
|
@ -99,7 +115,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||||
'assets/image/Logo_SplashScreen.png',
|
'assets/image/Logo_SplashScreen.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
errorBuilder: (_, __, ___) => Image.asset(
|
errorBuilder: (_, _, _) => Image.asset(
|
||||||
'assets/image/logo_sgizi.png',
|
'assets/image/logo_sgizi.png',
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../app_state.dart';
|
import 'package:s_gizi/app_state.dart';
|
||||||
import '../models/api_result_model.dart';
|
import 'package:s_gizi/models/api_result_model.dart';
|
||||||
import '../models/mobile_child_model.dart';
|
import 'package:s_gizi/models/mobile_child_model.dart';
|
||||||
import '../models/news_article_model.dart';
|
import 'package:s_gizi/models/news_article_model.dart';
|
||||||
import '../models/recommendation_response_model.dart';
|
import 'package:s_gizi/models/recommendation_response_model.dart';
|
||||||
import '../models/riwayat_response_model.dart';
|
import 'package:s_gizi/models/riwayat_response_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
ApiService({http.Client? client, String? baseUrl})
|
ApiService({http.Client? client, String? baseUrl})
|
||||||
|
|
@ -19,15 +20,16 @@ class ApiService {
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
|
|
||||||
static String _resolveDefaultBaseUrl() {
|
static String _resolveDefaultBaseUrl() {
|
||||||
// Web -> localhost browser, Android emulator -> 10.0.2.2
|
const configured = String.fromEnvironment('SGIZI_API_BASE_URL');
|
||||||
if (kIsWeb) {
|
if (configured.isNotEmpty) return configured;
|
||||||
return 'http://127.0.0.1:8000/api';
|
|
||||||
}
|
|
||||||
return 'http://10.0.2.2:8000/api';
|
|
||||||
//192.168.1.77//
|
|
||||||
//10.0.2.2 // ip address android emulator
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Override for physical devices:
|
||||||
|
// flutter run --dart-define=SGIZI_API_BASE_URL=http://<LAN-IP>:8000/api
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'http://192.168.1.69:8000/api';
|
||||||
|
}
|
||||||
|
return 'http://192.168.1.69:8000/api';
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> hitungGizi(Map<String, dynamic> data) async {
|
Future<Map<String, dynamic>> hitungGizi(Map<String, dynamic> data) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
|
|
@ -47,9 +49,121 @@ class ApiService {
|
||||||
throw Exception('Gagal API (${response.statusCode}): ${response.body}');
|
throw Exception('Gagal API (${response.statusCode}): ${response.body}');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendOtp(String phone) async {
|
Future<Map<String, dynamic>> login({
|
||||||
|
required String phone,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
Uri.parse('$baseUrl/send-otp'),
|
Uri.parse('$baseUrl/auth/login'),
|
||||||
|
headers: const {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'phone': phone, 'password': password}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'Login gagal.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format response login tidak valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = decoded['token'] as String? ?? '';
|
||||||
|
if (token.isEmpty) throw Exception('Token login kosong.');
|
||||||
|
|
||||||
|
final userJson = decoded['user'];
|
||||||
|
if (userJson is Map<String, dynamic>) {
|
||||||
|
await SgiziAppState.instance.saveSession(
|
||||||
|
token: token,
|
||||||
|
role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(),
|
||||||
|
user: Map<String, dynamic>.from(userJson),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _messageFromBody(String body, String fallback) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is String && message.trim().isNotEmpty) return message;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> registerSendOtp({
|
||||||
|
required String name,
|
||||||
|
required String phone,
|
||||||
|
required String parentGender,
|
||||||
|
required String password,
|
||||||
|
required String passwordConfirmation,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/auth/register/send-otp'),
|
||||||
|
headers: const {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'name': name,
|
||||||
|
'phone': phone,
|
||||||
|
'parent_gender': parentGender,
|
||||||
|
'password': password,
|
||||||
|
'password_confirmation': passwordConfirmation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> registerVerifyOtp({
|
||||||
|
required String phone,
|
||||||
|
required String otp,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/auth/register/verify'),
|
||||||
|
headers: const {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'phone': phone, 'otp': otp}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'OTP tidak valid.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format response verifikasi tidak valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = decoded['token'] as String? ?? '';
|
||||||
|
if (token.isEmpty) throw Exception('Token registrasi kosong.');
|
||||||
|
|
||||||
|
final userJson = decoded['user'];
|
||||||
|
if (userJson is Map<String, dynamic>) {
|
||||||
|
await SgiziAppState.instance.saveSession(
|
||||||
|
token: token,
|
||||||
|
role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(),
|
||||||
|
user: Map<String, dynamic>.from(userJson),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> forgotPassword({required String phone}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/auth/forgot-password'),
|
||||||
headers: const {
|
headers: const {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|
@ -58,13 +172,62 @@ class ApiService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal mengirim OTP: ${response.body}');
|
throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> verifyOtp(String phone, String otp) async {
|
Future<Map<String, dynamic>> resetPassword({
|
||||||
|
required String phone,
|
||||||
|
required String otp,
|
||||||
|
required String password,
|
||||||
|
required String passwordConfirmation,
|
||||||
|
}) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
Uri.parse('$baseUrl/verify-otp'),
|
Uri.parse('$baseUrl/auth/reset-password'),
|
||||||
|
headers: const {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'phone': phone,
|
||||||
|
'otp': otp,
|
||||||
|
'password': password,
|
||||||
|
'password_confirmation': passwordConfirmation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'Gagal reset password.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format response reset password tidak valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = decoded['token'] as String? ?? '';
|
||||||
|
if (token.isNotEmpty) {
|
||||||
|
final userJson = decoded['user'];
|
||||||
|
if (userJson is Map<String, dynamic>) {
|
||||||
|
await SgiziAppState.instance.saveSession(
|
||||||
|
token: token,
|
||||||
|
role: (userJson['role'] as String? ?? 'orang_tua')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
user: Map<String, dynamic>.from(userJson),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> verifyForgotPasswordOtp({
|
||||||
|
required String phone,
|
||||||
|
required String otp,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/auth/forgot-password/verify'),
|
||||||
headers: const {
|
headers: const {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|
@ -72,24 +235,21 @@ class ApiService {
|
||||||
body: jsonEncode({'phone': phone, 'otp': otp}),
|
body: jsonEncode({'phone': phone, 'otp': otp}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
final decoded = jsonDecode(response.body);
|
throw Exception(_messageFromBody(response.body, 'Gagal verifikasi OTP.'));
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
throw Exception('Format response API tidak valid.');
|
|
||||||
}
|
|
||||||
final token = decoded['token'] as String? ?? '';
|
|
||||||
if (token.isEmpty) throw Exception('Token login kosong.');
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Exception('OTP tidak valid: ${response.body}');
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format response verifikasi OTP tidak valid.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<MobileChildModel>> getChildren() async {
|
Future<List<MobileChildModel>> getChildren() async {
|
||||||
final response = await _client.get(
|
final response = await _client
|
||||||
Uri.parse('$baseUrl/children'),
|
.get(Uri.parse('$baseUrl/children'), headers: _headers())
|
||||||
headers: _headers(),
|
.timeout(const Duration(seconds: 12));
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal mengambil data anak: ${response.body}');
|
throw Exception('Gagal mengambil data anak: ${response.body}');
|
||||||
|
|
@ -138,8 +298,10 @@ class ApiService {
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
if (data['nama'] != null) 'nama': data['nama'],
|
if (data['nama'] != null) 'nama': data['nama'],
|
||||||
if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'],
|
if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'],
|
||||||
if (data['tanggal_lahir'] != null) 'tanggal_lahir': data['tanggal_lahir'],
|
if (data['tanggal_lahir'] != null)
|
||||||
if (data['jenis_kelamin'] != null) 'jenis_kelamin': data['jenis_kelamin'],
|
'tanggal_lahir': data['tanggal_lahir'],
|
||||||
|
if (data['jenis_kelamin'] != null)
|
||||||
|
'jenis_kelamin': data['jenis_kelamin'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -147,7 +309,8 @@ class ApiService {
|
||||||
throw Exception('Gagal memperbarui data anak: ${response.body}');
|
throw Exception('Gagal memperbarui data anak: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic> ||
|
||||||
|
decoded['data'] is! Map<String, dynamic>) {
|
||||||
throw Exception('Format response update anak tidak valid.');
|
throw Exception('Format response update anak tidak valid.');
|
||||||
}
|
}
|
||||||
return MobileChildModel.fromJson(decoded['data'] as Map<String, dynamic>);
|
return MobileChildModel.fromJson(decoded['data'] as Map<String, dynamic>);
|
||||||
|
|
@ -169,10 +332,9 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RiwayatResponseModel> getRiwayat({required int childId}) async {
|
Future<RiwayatResponseModel> getRiwayat({required int childId}) async {
|
||||||
final response = await _client.get(
|
final response = await _client
|
||||||
Uri.parse('$baseUrl/riwayat/$childId'),
|
.get(Uri.parse('$baseUrl/riwayat/$childId'), headers: _headers())
|
||||||
headers: _headers(),
|
.timeout(const Duration(seconds: 12));
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
|
@ -193,17 +355,16 @@ class ApiService {
|
||||||
int? childId,
|
int? childId,
|
||||||
int? riwayatId,
|
int? riwayatId,
|
||||||
}) async {
|
}) async {
|
||||||
final encodedStatus = Uri.encodeComponent(status.isEmpty ? 'latest' : status);
|
final encodedStatus = Uri.encodeComponent(
|
||||||
|
status.isEmpty ? 'latest' : status,
|
||||||
|
);
|
||||||
final uri = Uri.parse('$baseUrl/rekomendasi/$encodedStatus').replace(
|
final uri = Uri.parse('$baseUrl/rekomendasi/$encodedStatus').replace(
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
if (childId != null) 'child_id': '$childId',
|
if (childId != null) 'child_id': '$childId',
|
||||||
if (riwayatId != null) 'riwayat_id': '$riwayatId',
|
if (riwayatId != null) 'riwayat_id': '$riwayatId',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final response = await _client.get(
|
final response = await _client.get(uri, headers: _headers());
|
||||||
uri,
|
|
||||||
headers: _headers(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
|
@ -224,7 +385,9 @@ class ApiService {
|
||||||
}) async {
|
}) async {
|
||||||
// Online/Internet (Google News RSS) tetap lewat endpoint `/news`
|
// Online/Internet (Google News RSS) tetap lewat endpoint `/news`
|
||||||
// agar tidak bentrok dengan `/articles` yang sekarang khusus DB admin.
|
// agar tidak bentrok dengan `/articles` yang sekarang khusus DB admin.
|
||||||
final uri = Uri.parse('$baseUrl/news').replace(queryParameters: {'q': query});
|
final uri = Uri.parse(
|
||||||
|
'$baseUrl/news',
|
||||||
|
).replace(queryParameters: {'q': query});
|
||||||
final response = await _client.get(uri, headers: _headers());
|
final response = await _client.get(uri, headers: _headers());
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
|
@ -244,15 +407,15 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getProfile() async {
|
Future<Map<String, dynamic>> getProfile() async {
|
||||||
final response = await _client.get(
|
final response = await _client
|
||||||
Uri.parse('$baseUrl/profile'),
|
.get(Uri.parse('$baseUrl/profile'), headers: _headers())
|
||||||
headers: _headers(),
|
.timeout(const Duration(seconds: 12));
|
||||||
);
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal mengambil profil: ${response.body}');
|
throw Exception('Gagal mengambil profil: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic> ||
|
||||||
|
decoded['data'] is! Map<String, dynamic>) {
|
||||||
throw Exception('Format profil tidak valid.');
|
throw Exception('Format profil tidak valid.');
|
||||||
}
|
}
|
||||||
return decoded['data'] as Map<String, dynamic>;
|
return decoded['data'] as Map<String, dynamic>;
|
||||||
|
|
@ -262,6 +425,11 @@ class ApiService {
|
||||||
required String name,
|
required String name,
|
||||||
required String phone,
|
required String phone,
|
||||||
String? email,
|
String? email,
|
||||||
|
String? gender,
|
||||||
|
String? birthDate,
|
||||||
|
String? specialization,
|
||||||
|
String? experience,
|
||||||
|
String? strSip,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _client.put(
|
final response = await _client.put(
|
||||||
Uri.parse('$baseUrl/profile'),
|
Uri.parse('$baseUrl/profile'),
|
||||||
|
|
@ -270,18 +438,70 @@ class ApiService {
|
||||||
'name': name,
|
'name': name,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'email': (email ?? '').trim().isEmpty ? null : email?.trim(),
|
'email': (email ?? '').trim().isEmpty ? null : email?.trim(),
|
||||||
|
if ((gender ?? '').trim().isNotEmpty) 'parent_gender': gender!.trim(),
|
||||||
|
if ((birthDate ?? '').trim().isNotEmpty)
|
||||||
|
'tanggal_lahir': birthDate!.trim(),
|
||||||
|
if ((specialization ?? '').trim().isNotEmpty)
|
||||||
|
'specialization': specialization!.trim(),
|
||||||
|
if ((experience ?? '').trim().isNotEmpty)
|
||||||
|
'experience': experience!.trim(),
|
||||||
|
if ((strSip ?? '').trim().isNotEmpty) 'str_sip': strSip!.trim(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal memperbarui profil: ${response.body}');
|
throw Exception('Gagal memperbarui profil: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic> ||
|
||||||
|
decoded['data'] is! Map<String, dynamic>) {
|
||||||
throw Exception('Format update profil tidak valid.');
|
throw Exception('Format update profil tidak valid.');
|
||||||
}
|
}
|
||||||
return decoded['data'] as Map<String, dynamic>;
|
return decoded['data'] as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updatePassword({
|
||||||
|
required String oldPassword,
|
||||||
|
required String newPassword,
|
||||||
|
required String newPasswordConfirmation,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.put(
|
||||||
|
Uri.parse('$baseUrl/profile/password'),
|
||||||
|
headers: _headers(),
|
||||||
|
body: jsonEncode({
|
||||||
|
'old_password': oldPassword,
|
||||||
|
'password': newPassword,
|
||||||
|
'password_confirmation': newPasswordConfirmation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Password lama tidak sesuai.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logoutAllDevices() async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/profile/logout-all'),
|
||||||
|
headers: _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Gagal logout semua perangkat.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAccount() async {
|
||||||
|
final response = await _client.delete(
|
||||||
|
Uri.parse('$baseUrl/profile'),
|
||||||
|
headers: _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'Gagal menghapus akun.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ambil artikel dari database `s_gizi` (tanpa query News API).
|
/// Ambil artikel dari database `s_gizi` (tanpa query News API).
|
||||||
Future<List<NewsArticleModel>> getArticlesDb() async {
|
Future<List<NewsArticleModel>> getArticlesDb() async {
|
||||||
final uri = Uri.parse('$baseUrl/articles');
|
final uri = Uri.parse('$baseUrl/articles');
|
||||||
|
|
@ -306,8 +526,9 @@ class ApiService {
|
||||||
Future<List<Map<String, dynamic>>> getConsultationRooms({
|
Future<List<Map<String, dynamic>>> getConsultationRooms({
|
||||||
required int childId,
|
required int childId,
|
||||||
}) async {
|
}) async {
|
||||||
final uri = Uri.parse('$baseUrl/consultation/rooms')
|
final uri = Uri.parse(
|
||||||
.replace(queryParameters: {'child_id': '$childId'});
|
'$baseUrl/consultation/rooms',
|
||||||
|
).replace(queryParameters: {'child_id': '$childId'});
|
||||||
final response = await _client.get(uri, headers: _headers());
|
final response = await _client.get(uri, headers: _headers());
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal mengambil room konsultasi: ${response.body}');
|
throw Exception('Gagal mengambil room konsultasi: ${response.body}');
|
||||||
|
|
@ -318,6 +539,21 @@ class ApiService {
|
||||||
return data.whereType<Map<String, dynamic>>().toList();
|
return data.whereType<Map<String, dynamic>>().toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getNutritionists() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(Uri.parse('$baseUrl/nutritionists'), headers: _headers())
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Gagal memuat ahli gizi.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final data = decoded is Map<String, dynamic> ? decoded['data'] : null;
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return data.whereType<Map<String, dynamic>>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> openConsultationRoom({
|
Future<Map<String, dynamic>> openConsultationRoom({
|
||||||
required int childId,
|
required int childId,
|
||||||
required String expertId,
|
required String expertId,
|
||||||
|
|
@ -342,7 +578,8 @@ class ApiService {
|
||||||
throw Exception('Gagal membuka room konsultasi: ${response.body}');
|
throw Exception('Gagal membuka room konsultasi: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic> ||
|
||||||
|
decoded['data'] is! Map<String, dynamic>) {
|
||||||
throw Exception('Format room konsultasi tidak valid.');
|
throw Exception('Format room konsultasi tidak valid.');
|
||||||
}
|
}
|
||||||
return decoded['data'] as Map<String, dynamic>;
|
return decoded['data'] as Map<String, dynamic>;
|
||||||
|
|
@ -367,20 +604,34 @@ class ApiService {
|
||||||
Future<Map<String, dynamic>> sendConsultationMessage({
|
Future<Map<String, dynamic>> sendConsultationMessage({
|
||||||
required int roomId,
|
required int roomId,
|
||||||
required String message,
|
required String message,
|
||||||
|
int? measurementId,
|
||||||
}) async {
|
}) async {
|
||||||
|
final body = <String, dynamic>{'message': message};
|
||||||
|
if (measurementId != null) {
|
||||||
|
body['measurement_id'] = measurementId;
|
||||||
|
}
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'),
|
Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'),
|
||||||
headers: _headers(),
|
headers: _headers(),
|
||||||
body: jsonEncode({'message': message}),
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[sendConsultationMessage] status=${response.statusCode} body=${response.body}',
|
||||||
);
|
);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Gagal kirim pesan konsultasi: ${response.body}');
|
throw Exception('Gagal kirim pesan konsultasi: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic>) {
|
||||||
throw Exception('Format response kirim pesan tidak valid.');
|
throw Exception('Format response kirim pesan tidak valid.');
|
||||||
}
|
}
|
||||||
return decoded['data'] as Map<String, dynamic>;
|
final data = decoded['data'];
|
||||||
|
if (data is Map<String, dynamic>) return data;
|
||||||
|
|
||||||
|
final status = decoded['status'] == true || decoded['success'] == true;
|
||||||
|
if (status || decoded.containsKey('message')) return decoded;
|
||||||
|
|
||||||
|
throw Exception('Format response kirim pesan tidak valid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> sendConsultationExpertReply({
|
Future<Map<String, dynamic>> sendConsultationExpertReply({
|
||||||
|
|
@ -396,12 +647,81 @@ class ApiService {
|
||||||
throw Exception('Gagal kirim balasan ahli: ${response.body}');
|
throw Exception('Gagal kirim balasan ahli: ${response.body}');
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic> || decoded['data'] is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic> ||
|
||||||
|
decoded['data'] is! Map<String, dynamic>) {
|
||||||
throw Exception('Format response balasan ahli tidak valid.');
|
throw Exception('Format response balasan ahli tidak valid.');
|
||||||
}
|
}
|
||||||
return decoded['data'] as Map<String, dynamic>;
|
return decoded['data'] as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getNutritionistDashboard() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(Uri.parse('$baseUrl/nutritionist/dashboard'), headers: _headers())
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Gagal memuat dashboard ahli gizi.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format dashboard ahli gizi tidak valid.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getNutritionistRoomMessages({
|
||||||
|
required int roomId,
|
||||||
|
}) async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'),
|
||||||
|
headers: _headers(),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Gagal memuat chat konsultasi.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format chat konsultasi tidak valid.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> sendNutritionistMessage({
|
||||||
|
required int roomId,
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'),
|
||||||
|
headers: _headers(),
|
||||||
|
body: jsonEncode({'message': message}),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(_messageFromBody(response.body, 'Gagal mengirim pesan.'));
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Format kirim pesan tidak valid.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> closeNutritionistConsultation({required int roomId}) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse('$baseUrl/nutritionist/consultations/$roomId/close'),
|
||||||
|
headers: _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception(
|
||||||
|
_messageFromBody(response.body, 'Gagal menandai konsultasi selesai.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateConsultationRoomStatus({
|
Future<void> updateConsultationRoomStatus({
|
||||||
required int roomId,
|
required int roomId,
|
||||||
required String status,
|
required String status,
|
||||||
|
|
@ -416,6 +736,20 @@ class ApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> markConsultationMeasurementShared({
|
||||||
|
required int roomId,
|
||||||
|
required int measurementId,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.patch(
|
||||||
|
Uri.parse('$baseUrl/consultation/rooms/$roomId/shared-measurement'),
|
||||||
|
headers: _headers(),
|
||||||
|
body: jsonEncode({'measurement_id': measurementId}),
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception('Gagal menandai update perkembangan: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, String> _headers() {
|
Map<String, String> _headers() {
|
||||||
final token = SgiziAppState.instance.authToken;
|
final token = SgiziAppState.instance.authToken;
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||