Perbarui Aplikasi

This commit is contained in:
Ranggaay 2026-05-14 01:06:44 +07:00
parent 0585d23abc
commit 0b9dfbea46
306 changed files with 40373 additions and 0 deletions

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
**/build/
Android/flutter_app/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "66dd93f9a27ffe2a9bfc8297506ce066ff51265f"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: android
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: ios
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: linux
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: macos
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: web
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: windows
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -0,0 +1,16 @@
# s_gizi
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@ -0,0 +1 @@
font_awesome

View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
Android/flutter_app/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,52 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.s_gizi"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.s_gizi"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="s_gizi"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.example.s_gizi
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

34
Android/flutter_app/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.sGizi;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>S Gizi</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>s_gizi</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@ -0,0 +1,493 @@
import 'package:flutter/material.dart';
const String sgiziLogoAsset = 'assets/image/logo_sgizi.png';
class SgColors {
const SgColors._();
static const primary = Color(0xFF4B8E96);
static const primaryDark = Color(0xFF2F737A);
static const secondary = Color(0xFFA8D5BA);
static const background = Color(0xFFF5F7F6);
static const surface = Colors.white;
static const textPrimary = Color(0xFF1E1E1E);
static const textSecondary = Color(0xFF6B7280);
static const border = Color(0xFFE0E7E4);
static const danger = Color(0xFFE53935);
static const warning = Color(0xFFF59E0B);
static const success = Color(0xFF34A853);
}
class AppTypography {
const AppTypography._();
static const h1 = TextStyle(
fontSize: 24,
height: 1.2,
fontWeight: FontWeight.w800,
color: SgColors.textPrimary,
);
static const h2 = TextStyle(
fontSize: 18,
height: 1.3,
fontWeight: FontWeight.w700,
color: SgColors.textPrimary,
);
static const h3 = TextStyle(
fontSize: 15,
height: 1.35,
fontWeight: FontWeight.w600,
color: SgColors.textPrimary,
);
static const body = TextStyle(
fontSize: 14,
height: 1.5,
fontWeight: FontWeight.w400,
color: SgColors.textSecondary,
);
static const caption = TextStyle(
fontSize: 12,
height: 1.35,
fontWeight: FontWeight.w500,
color: SgColors.textSecondary,
);
}
class AppLogo extends StatelessWidget {
const AppLogo({super.key, this.size = 56, this.showLabel = false});
final double size;
final bool showLabel;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(size * 0.24),
child: Image.asset(
sgiziLogoAsset,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: SgColors.primary,
borderRadius: BorderRadius.circular(size * 0.24),
),
child: const Icon(Icons.bolt, color: Colors.white),
),
),
),
if (showLabel) ...[
const SizedBox(width: 12),
Text(
'S-Gizi',
style: AppTypography.h1.copyWith(
color: SgColors.primaryDark,
fontSize: 22,
),
),
],
],
);
}
}
class ChildAvatar extends StatelessWidget {
const ChildAvatar({
super.key,
required this.name,
required this.gender,
this.photoUrl,
this.radius = 28,
});
final String name;
final String gender;
final String? photoUrl;
final double radius;
@override
Widget build(BuildContext context) {
final imageUrl = photoUrl?.trim();
if (imageUrl != null && imageUrl.isNotEmpty) {
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFFD9EEE7),
backgroundImage: NetworkImage(imageUrl),
);
}
final isFemale = gender.toLowerCase().startsWith('p');
final accent = isFemale ? const Color(0xFFFF8FA3) : SgColors.primary;
return CircleAvatar(
radius: radius,
backgroundColor: accent.withValues(alpha: 0.12),
child: Icon(
isFemale ? Icons.face_3_rounded : Icons.face_rounded,
color: accent,
size: radius,
),
);
}
}
class HealthCard extends StatelessWidget {
const HealthCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.color = SgColors.surface,
this.borderColor,
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color color;
final Color? borderColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
margin: margin,
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: borderColor ?? SgColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: child,
);
if (onTap == null) {
return card;
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: card,
),
);
}
}
class StatusBadge extends StatelessWidget {
const StatusBadge({
super.key,
required this.text,
this.color = SgColors.success,
});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.36)),
),
child: Text(
text,
style: AppTypography.caption.copyWith(
color: color,
fontWeight: FontWeight.w800,
),
),
);
}
}
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.isOutlined = false,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final bool isOutlined;
@override
Widget build(BuildContext context) {
final child = Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 20), const SizedBox(width: 8)],
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
],
);
if (isOutlined) {
return SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: SgColors.primary,
side: const BorderSide(color: SgColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: child,
),
);
}
return SizedBox(
width: double.infinity,
height: 56,
child: FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: SgColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 1,
shadowColor: SgColors.primary.withValues(alpha: 0.32),
),
child: child,
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.title,
required this.message,
this.actionLabel,
this.onAction,
});
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: HealthCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFE9F6F2),
child: Icon(Icons.inbox_outlined, color: SgColors.primary),
),
const SizedBox(height: 16),
Text(title, style: AppTypography.h2, textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(
message,
style: AppTypography.body,
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
PrimaryButton(label: actionLabel!, onPressed: onAction),
],
],
),
),
),
);
}
}
class ErrorState extends StatelessWidget {
const ErrorState({super.key, required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: HealthCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFFFEBEE),
child: Icon(Icons.wifi_off_rounded, color: SgColors.danger),
),
const SizedBox(height: 16),
const Text('Terjadi Kendala', style: AppTypography.h2),
const SizedBox(height: 8),
Text(
message,
style: AppTypography.body,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
PrimaryButton(
label: 'Coba Lagi',
icon: Icons.refresh_rounded,
onPressed: onRetry,
),
],
),
),
),
);
}
}
class MetricProgress extends StatelessWidget {
const MetricProgress({
super.key,
required this.label,
required this.description,
required this.status,
required this.value,
required this.icon,
});
final String label;
final String description;
final String status;
final double value;
final IconData icon;
@override
Widget build(BuildContext context) {
final clampedValue = value.clamp(0.0, 1.0).toDouble();
return HealthCard(
margin: const EdgeInsets.only(bottom: 12),
child: Column(
children: [
Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: const Color(0xFFE9F6F2),
child: Icon(icon, color: SgColors.primary, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.h3),
const SizedBox(height: 2),
Text(description, style: AppTypography.caption),
],
),
),
Text(
status.toUpperCase(),
textAlign: TextAlign.right,
style: AppTypography.caption.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: clampedValue),
duration: const Duration(milliseconds: 850),
curve: Curves.easeOutCubic,
builder: (context, animatedValue, _) {
return LinearProgressIndicator(
value: animatedValue,
minHeight: 8,
backgroundColor: const Color(0xFFEAF4F1),
valueColor: const AlwaysStoppedAnimation(SgColors.primary),
);
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text('Kurang', style: AppTypography.caption),
Text(
'Ideal',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: SgColors.primary,
),
),
Text('Berlebih', style: AppTypography.caption),
],
),
],
),
);
}
}
PageRouteBuilder<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>(
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.03, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
);
},
);
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/foundation.dart';
import 'models/mobile_child_model.dart';
class SgiziAppState extends ChangeNotifier {
SgiziAppState._();
static final SgiziAppState instance = SgiziAppState._();
String? authToken;
int? activeChildId;
List<MobileChildModel> children = const [];
Map<String, dynamic>? profileData;
bool get isAuthenticated => authToken != null && authToken!.isNotEmpty;
MobileChildModel? get activeChild {
final id = activeChildId;
if (id == null || children.isEmpty) return null;
for (final child in children) {
if (child.id == id) return child;
}
return null;
}
void setToken(String token) {
authToken = token;
notifyListeners();
}
void setChildren(List<MobileChildModel> value) {
children = value;
activeChildId ??= value.isNotEmpty ? value.first.id : null;
if (value.every((child) => child.id != activeChildId)) {
activeChildId = value.isNotEmpty ? value.first.id : null;
}
notifyListeners();
}
void setProfileData(Map<String, dynamic> value) {
profileData = value;
notifyListeners();
}
void setActiveChild(int id) {
activeChildId = id;
notifyListeners();
}
void updateChildMeasurementSnapshot({
required int childId,
required String latestStatus,
required String latestMeasurementAt,
}) {
children = [
for (final child in children)
if (child.id == childId)
child.copyWith(
latestStatus: latestStatus,
latestMeasurementAt: latestMeasurementAt,
)
else
child,
];
notifyListeners();
}
void logout() {
authToken = null;
activeChildId = null;
children = const [];
profileData = null;
notifyListeners();
}
}

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'app_design.dart';
import 'services/local_notification_service.dart';
import 'screens/splash_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalNotificationService.instance.init();
runApp(const GiziApp());
}
class GiziApp extends StatelessWidget {
const GiziApp({super.key});
@override
Widget build(BuildContext context) {
const seedColor = Color(0xFF4B8E96);
return MaterialApp(
title: 'S-Gizi',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
fontFamily: 'Roboto',
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
primary: seedColor,
secondary: SgColors.secondary,
surface: SgColors.surface,
),
scaffoldBackgroundColor: SgColors.background,
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: SgColors.surface,
foregroundColor: SgColors.textPrimary,
titleTextStyle: AppTypography.h2,
),
textTheme: const TextTheme(
headlineLarge: AppTypography.h1,
titleLarge: AppTypography.h2,
titleMedium: AppTypography.h3,
bodyMedium: AppTypography.body,
bodySmall: AppTypography.caption,
),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: const BorderSide(color: Color(0xFFE0ECE9)),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD3E4E1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD3E4E1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: seedColor, width: 1.5),
),
),
),
home: const SplashScreen(),
);
}
}

View File

@ -0,0 +1,162 @@
class ApiResultModel {
const ApiResultModel({
required this.identitas,
required this.zScore,
required this.kategori,
required this.statusGabungan,
required this.rekomendasi,
this.measurement,
});
final IdentitasModel identitas;
final ZScoreModel zScore;
final KategoriModel kategori;
final String statusGabungan;
final List<RekomendasiModel> rekomendasi;
final AnalysisMeasurementModel? measurement;
factory ApiResultModel.fromJson(Map<String, dynamic> json) {
return ApiResultModel(
identitas: IdentitasModel.fromJson(
json['identitas'] as Map<String, dynamic>? ?? const {},
),
zScore: ZScoreModel.fromJson(
json['zscore'] as Map<String, dynamic>? ?? const {},
),
kategori: KategoriModel.fromJson(
json['kategori'] as Map<String, dynamic>? ?? const {},
),
statusGabungan: json['status_gabungan'] as String? ?? '-',
rekomendasi: (json['rekomendasi'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(RekomendasiModel.fromJson)
.toList(),
measurement: json['measurement'] is Map<String, dynamic>
? AnalysisMeasurementModel.fromJson(
json['measurement'] as Map<String, dynamic>,
)
: null,
);
}
}
class AnalysisMeasurementModel {
const AnalysisMeasurementModel({
required this.id,
required this.childId,
required this.childName,
required this.tanggalUkur,
required this.caraUkur,
});
final int id;
final int childId;
final String childName;
final String tanggalUkur;
final String? caraUkur;
factory AnalysisMeasurementModel.fromJson(Map<String, dynamic> json) {
return AnalysisMeasurementModel(
id: (json['id'] as num?)?.toInt() ?? 0,
childId: (json['child_id'] as num?)?.toInt() ?? 0,
childName: json['child_name'] as String? ?? '-',
tanggalUkur: json['tanggal_ukur'] as String? ?? '-',
caraUkur: json['cara_ukur'] as String?,
);
}
}
class IdentitasModel {
const IdentitasModel({required this.umurBulan, required this.jenisKelamin});
final double umurBulan;
final String jenisKelamin;
factory IdentitasModel.fromJson(Map<String, dynamic> json) {
double parseNum(dynamic v) {
if (v is num) return v.toDouble();
return double.nan;
}
return IdentitasModel(
umurBulan: parseNum(json['umur_bulan']),
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
);
}
}
class ZScoreModel {
const ZScoreModel({required this.bbu, required this.tbu, required this.bbtb});
final double bbu;
final double tbu;
final double bbtb;
factory ZScoreModel.fromJson(Map<String, dynamic> json) {
double parseNum(dynamic v) {
if (v is num) return v.toDouble();
return double.nan;
}
return ZScoreModel(
bbu: parseNum(json['bbu']),
tbu: parseNum(json['tbu']),
bbtb: parseNum(json['bbtb']),
);
}
}
class KategoriModel {
const KategoriModel({
required this.bbu,
required this.tbu,
required this.bbtb,
});
final String bbu;
final String tbu;
final String bbtb;
factory KategoriModel.fromJson(Map<String, dynamic> json) {
return KategoriModel(
bbu: json['bbu'] as String? ?? '-',
tbu: json['tbu'] as String? ?? '-',
bbtb: json['bbtb'] as String? ?? '-',
);
}
}
class RekomendasiModel {
const RekomendasiModel({
required this.menu,
required this.kalori,
required this.protein,
required this.lemak,
required this.karbohidrat,
required this.alasan,
});
final String menu;
final int kalori;
final int protein;
final int lemak;
final int karbohidrat;
final String alasan;
factory RekomendasiModel.fromJson(Map<String, dynamic> json) {
int parseInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
return RekomendasiModel(
menu: json['menu'] as String? ?? '-',
kalori: parseInt(json['kalori']),
protein: parseInt(json['protein']),
lemak: parseInt(json['lemak']),
karbohidrat: parseInt(json['karbohidrat']),
alasan: json['alasan'] as String? ?? '-',
);
}
}

View File

@ -0,0 +1,72 @@
class ChildModel {
const ChildModel({
required this.ageInMonths,
required this.weightKg,
required this.heightCm,
required this.gender,
required this.measurementPosition,
});
final double ageInMonths;
final double weightKg;
final double heightCm;
final String gender;
final String measurementPosition;
// =============================
// NORMALIZATION
// =============================
String get normalizedGender {
final g = gender.toLowerCase().trim();
if (g == 'l' || g == 'laki' || g == 'laki-laki' || g == 'male') {
return 'male';
}
if (g == 'p' || g == 'perempuan' || g == 'female') {
return 'female';
}
throw Exception('Gender tidak dikenali: $gender');
}
String get normalizedPosition {
final p = measurementPosition.toLowerCase().trim();
if (p.contains('berdiri') || p.contains('standing')) {
return 'standing';
}
if (p.contains('terlentang') ||
p.contains('lying') ||
p.contains('recumbent')) {
return 'lying';
}
throw Exception('Posisi tidak dikenali: $measurementPosition');
}
// =============================
// WHO RULE
// =============================
bool get usesLengthReference => ageInMonths < 24;
bool get needsStandingToRecumbentAdjustment =>
usesLengthReference && normalizedPosition == 'standing';
double get adjustedHeightCm {
if (needsStandingToRecumbentAdjustment) {
return heightCm + 0.7;
}
return heightCm;
}
// =============================
// INDICATORS
// =============================
String get weightForAgeIndicator => 'wfa';
String get heightForAgeIndicator => 'hfa';
String get weightForHeightIndicator => usesLengthReference ? 'wfl' : 'wfh';
}

View File

@ -0,0 +1,54 @@
class MobileChildModel {
const MobileChildModel({
required this.id,
required this.nama,
required this.tanggalLahir,
required this.jenisKelamin,
this.photoUrl,
this.latestStatus,
this.latestMeasurementAt,
});
final int id;
final String nama;
final String tanggalLahir;
final String jenisKelamin;
final String? photoUrl;
final String? latestStatus;
final String? latestMeasurementAt;
factory MobileChildModel.fromJson(Map<String, dynamic> json) {
return MobileChildModel(
id: (json['id'] as num?)?.toInt() ?? 0,
nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
photoUrl:
json['photo_url'] as String? ??
json['foto_url'] as String? ??
json['avatar_url'] as String?,
latestStatus: json['latest_status'] as String?,
latestMeasurementAt: json['latest_measurement_at'] as String?,
);
}
MobileChildModel copyWith({
int? id,
String? nama,
String? tanggalLahir,
String? jenisKelamin,
String? photoUrl,
String? latestStatus,
String? latestMeasurementAt,
}) {
return MobileChildModel(
id: id ?? this.id,
nama: nama ?? this.nama,
tanggalLahir: tanggalLahir ?? this.tanggalLahir,
jenisKelamin: jenisKelamin ?? this.jenisKelamin,
photoUrl: photoUrl ?? this.photoUrl,
latestStatus: latestStatus ?? this.latestStatus,
latestMeasurementAt: latestMeasurementAt ?? this.latestMeasurementAt,
);
}
}

View File

@ -0,0 +1,49 @@
class NewsArticleModel {
const NewsArticleModel({
required this.id,
required this.title,
required this.description,
required this.content,
required this.category,
required this.createdAt,
this.sourceName,
this.image,
this.url,
});
final int id;
final String title;
final String description;
final String content;
final String category;
final String createdAt;
final String? sourceName;
final String? image;
final String? url;
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
return NewsArticleModel(
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '-',
description:
(json['description'] as String? ?? json['excerpt'] as String? ?? '-')
.trim(),
content: (json['content'] as String? ?? '').trim(),
category: (json['category'] as String? ?? 'Nutrisi Anak').trim(),
createdAt:
(json['created_at'] as String? ?? json['published_at'] as String? ?? '')
.trim(),
sourceName: (json['source_name'] as String? ?? '').trim().isEmpty
? null
: json['source_name'] as String?,
image: (json['image'] as String? ?? json['image_url'] as String? ?? '')
.trim()
.isEmpty
? null
: (json['image'] as String? ?? json['image_url'] as String?),
url: (json['url'] as String? ?? '').trim().isEmpty
? null
: json['url'] as String?,
);
}
}

View File

@ -0,0 +1,72 @@
import 'api_result_model.dart';
class RecommendationResponseModel {
const RecommendationResponseModel({
required this.requestedStatus,
required this.resolvedStatus,
required this.primaryCategory,
required this.matchedCategories,
required this.items,
this.measurement,
});
final String requestedStatus;
final String resolvedStatus;
final String primaryCategory;
final List<String> matchedCategories;
final List<RekomendasiModel> items;
final RecommendationMeasurementModel? measurement;
factory RecommendationResponseModel.fromJson(Map<String, dynamic> json) {
final normalized =
json['normalized_status'] as Map<String, dynamic>? ?? const {};
final rawCategories = normalized['matched_categories'];
return RecommendationResponseModel(
requestedStatus: json['requested_status'] as String? ?? '-',
resolvedStatus:
json['resolved_status'] as String? ??
json['status'] as String? ??
'-',
primaryCategory: normalized['primary_category'] as String? ?? 'Normal',
matchedCategories: rawCategories is List
? rawCategories.whereType<String>().toList()
: const ['Normal'],
items: (json['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(RekomendasiModel.fromJson)
.toList(),
measurement: json['measurement'] is Map<String, dynamic>
? RecommendationMeasurementModel.fromJson(
json['measurement'] as Map<String, dynamic>,
)
: null,
);
}
}
class RecommendationMeasurementModel {
const RecommendationMeasurementModel({
required this.id,
required this.childId,
required this.childName,
required this.tanggalUkur,
required this.umurBulan,
});
final int id;
final int childId;
final String childName;
final String? tanggalUkur;
final double? umurBulan;
factory RecommendationMeasurementModel.fromJson(Map<String, dynamic> json) {
return RecommendationMeasurementModel(
id: (json['id'] as num?)?.toInt() ?? 0,
childId: (json['child_id'] as num?)?.toInt() ?? 0,
childName: json['child_name'] as String? ?? '-',
tanggalUkur: json['tanggal_ukur'] as String?,
umurBulan: (json['umur_bulan'] as num?)?.toDouble(),
);
}
}

View File

@ -0,0 +1,120 @@
class RiwayatResponseModel {
const RiwayatResponseModel({required this.child, required this.riwayat});
final ChildInfoModel child;
final List<RiwayatItemModel> riwayat;
factory RiwayatResponseModel.fromJson(Map<String, dynamic> json) {
final rawList = json['riwayat'];
final items = rawList is List
? rawList
.whereType<Map<String, dynamic>>()
.map(RiwayatItemModel.fromJson)
.toList()
: <RiwayatItemModel>[];
return RiwayatResponseModel(
child: ChildInfoModel.fromJson(
json['child'] as Map<String, dynamic>? ?? const {},
),
riwayat: items,
);
}
}
class ChildInfoModel {
const ChildInfoModel({
required this.id,
required this.nama,
required this.tanggalLahir,
required this.jenisKelamin,
this.photoUrl,
});
final int id;
final String nama;
final String tanggalLahir;
final String jenisKelamin;
final String? photoUrl;
factory ChildInfoModel.fromJson(Map<String, dynamic> json) {
return ChildInfoModel(
id: (json['id'] as num?)?.toInt() ?? 0,
nama: json['nama'] as String? ?? '-',
tanggalLahir: json['tanggal_lahir'] as String? ?? '-',
jenisKelamin: json['jenis_kelamin'] as String? ?? '-',
photoUrl:
json['photo_url'] as String? ??
json['foto_url'] as String? ??
json['avatar_url'] as String?,
);
}
}
class RiwayatKategoriModel {
const RiwayatKategoriModel({
required this.bbu,
required this.tbu,
required this.bbtb,
});
final String? bbu;
final String? tbu;
final String? bbtb;
factory RiwayatKategoriModel.fromJson(Map<String, dynamic> json) {
return RiwayatKategoriModel(
bbu: json['bbu'] as String?,
tbu: json['tbu'] as String?,
bbtb: json['bbtb'] as String?,
);
}
}
class RiwayatItemModel {
const RiwayatItemModel({
required this.id,
required this.berat,
required this.tinggi,
required this.tanggalUkur,
required this.caraUkur,
required this.umurBulan,
required this.statusGabungan,
required this.kategori,
required this.zBbu,
required this.zTbu,
required this.zBbtb,
});
final int id;
final double berat;
final double tinggi;
final String tanggalUkur;
final String? caraUkur;
final double umurBulan;
final String statusGabungan;
final RiwayatKategoriModel kategori;
final double? zBbu;
final double? zTbu;
final double? zBbtb;
factory RiwayatItemModel.fromJson(Map<String, dynamic> json) {
final z = json['z_score'] as Map<String, dynamic>? ?? const {};
final kategori = json['kategori'] as Map<String, dynamic>? ?? const {};
double? parseNullableNum(dynamic v) => v is num ? v.toDouble() : null;
return RiwayatItemModel(
id: (json['id'] as num?)?.toInt() ?? 0,
berat: (json['berat'] as num?)?.toDouble() ?? double.nan,
tinggi: (json['tinggi'] as num?)?.toDouble() ?? double.nan,
tanggalUkur: json['tanggal_ukur'] as String? ?? '-',
caraUkur: json['cara_ukur'] as String?,
umurBulan: (json['umur_bulan'] as num?)?.toDouble() ?? double.nan,
statusGabungan: json['status_gabungan'] as String? ?? '-',
kategori: RiwayatKategoriModel.fromJson(kategori),
zBbu: parseNullableNum(z['bbu']),
zTbu: parseNullableNum(z['tbu']),
zBbtb: parseNullableNum(z['bbtb']),
);
}
}

View File

@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:shimmer/shimmer.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
import 'edit_profile_screen.dart';
class AccountInfoScreen extends StatefulWidget {
const AccountInfoScreen({super.key});
@override
State<AccountInfoScreen> createState() => _AccountInfoScreenState();
}
class _AccountInfoScreenState extends State<AccountInfoScreen> {
final _api = ApiService();
final _state = SgiziAppState.instance;
late Future<Map<String, dynamic>> _future;
Map<String, dynamic>? _profileCache;
@override
void initState() {
super.initState();
_future = _api.getProfile().then((value) {
_state.setProfileData(value);
return value;
});
}
void _retry() => setState(() {
_future = _api.getProfile().then((value) {
_state.setProfileData(value);
return value;
});
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
title: const Text('Informasi Akun'),
backgroundColor: const Color(0xFFF5F7F6),
),
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _AccountSkeleton();
}
if (snapshot.hasError) {
return ErrorState(
message: 'Informasi akun gagal dimuat.',
onRetry: _retry,
);
}
final profile = _profileCache ?? snapshot.data ?? const <String, dynamic>{};
final name = (profile['name'] as String? ?? '-').trim();
final phone = (profile['phone'] as String? ?? '-').trim();
final email = (profile['email'] as String? ?? '-').trim();
final joinedAt = (profile['joined_at'] as String? ?? '-').trim();
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HealthCard(
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 78,
height: 78,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFEAF7F7),
border: Border.all(color: const Color(0xFF0B7A86), width: 1.8),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: ClipOval(
child: Image.asset(
'assets/image/onboarding_consultation.png',
fit: BoxFit.cover,
),
),
),
),
Positioned(
right: 2,
bottom: 2,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFF34C759),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).moveY(
begin: 0,
end: -2,
duration: 1800.ms,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: AppTypography.h2, maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 3),
Text(phone, style: AppTypography.body, maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 6),
const StatusBadge(text: 'Akun Terverifikasi', color: Color(0xFF0B7A86)),
],
),
),
const SizedBox(width: 10),
IconButton(
onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
if (updated != null && mounted) {
setState(() {
_profileCache = updated;
_future = Future.value(updated);
});
_state.setProfileData(updated);
}
},
icon: const Icon(LucideIcons.pencil, color: Color(0xFF0B7A86)),
),
],
),
),
const SizedBox(height: 16),
Text(
'Data Akun',
style: AppTypography.h2.copyWith(color: SgColors.textPrimary),
),
const SizedBox(height: 10),
HealthCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_InfoRow(
icon: LucideIcons.user,
title: 'Nama Lengkap',
value: name,
),
const Divider(height: 1, color: SgColors.border),
_InfoRow(
icon: LucideIcons.messageCircle,
title: 'Nomor Telepon',
value: phone,
),
const Divider(height: 1, color: SgColors.border),
_InfoRow(
icon: LucideIcons.info,
title: 'Email',
value: email,
),
const Divider(height: 1, color: SgColors.border),
_InfoRow(
icon: LucideIcons.calendarDays,
title: 'Tanggal Bergabung',
value: joinedAt,
),
],
),
),
const SizedBox(height: 16),
PrimaryButton(
label: 'Edit Profile',
icon: LucideIcons.arrowRight,
onPressed: () async {
final updated = await Navigator.of(context).push<Map<String, dynamic>>(
fadeRoute(
EditProfileScreen(
initialName: name,
initialPhone: phone,
initialEmail: email == '-' ? '' : email,
),
),
);
if (updated != null && mounted) {
setState(() {
_profileCache = updated;
_future = Future.value(updated);
});
_state.setProfileData(updated);
}
},
),
],
),
).animate().fadeIn(duration: 220.ms).slideY(begin: 0.02, end: 0);
},
),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.title,
required this.value,
});
final IconData icon;
final String title;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF7FD6C2).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF0B7A86), size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.caption),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.h3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
class _AccountSkeleton extends StatelessWidget {
const _AccountSkeleton();
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: const Color(0xFFE8EEEC),
highlightColor: const Color(0xFFF7FAF9),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
children: [
Container(height: 120, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))),
const SizedBox(height: 16),
Container(height: 230, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20))),
],
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../app_state.dart';
import 'add_child_screen.dart';
class ChildrenScreen extends StatelessWidget {
const ChildrenScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pilih Anak')),
backgroundColor: SgColors.background,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const Text('Kelola Anak', style: AppTypography.h1),
const SizedBox(height: 8),
const Text(
'Pilih profil anak untuk melihat perkembangan dan rekomendasi gizi yang sesuai.',
style: AppTypography.body,
),
const SizedBox(height: 24),
...SgiziAppState.instance.children.map(
(child) => HealthCard(
margin: const EdgeInsets.only(bottom: 16),
onTap: () {
SgiziAppState.instance.setActiveChild(child.id);
Navigator.pop(context);
},
color: child.id == SgiziAppState.instance.activeChildId
? const Color(0xFFEAF7F7)
: Colors.white,
borderColor: child.id == SgiziAppState.instance.activeChildId
? SgColors.primary
: SgColors.border,
child: Row(
children: [
Stack(
children: [
CircleAvatar(
radius: 30,
backgroundColor:
child.id == SgiziAppState.instance.activeChildId
? const Color(0xFFD9EEE7)
: const Color(0xFFF0F2F1),
child: Icon(
child.jenisKelamin == 'P'
? Icons.face_3_rounded
: Icons.face_rounded,
color:
child.id == SgiziAppState.instance.activeChildId
? SgColors.primary
: SgColors.textSecondary,
),
),
if (child.id == SgiziAppState.instance.activeChildId)
Positioned(
right: 0,
top: 0,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: SgColors.primary,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 14,
),
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
child.nama,
style: AppTypography.h2,
overflow: TextOverflow.ellipsis,
),
),
if (child.id ==
SgiziAppState.instance.activeChildId) ...[
const SizedBox(width: 8),
const StatusBadge(
text: 'Aktif',
color: SgColors.primary,
),
],
],
),
const SizedBox(height: 6),
Text(
child.jenisKelamin == 'P'
? 'Perempuan'
: 'Laki-laki',
style: AppTypography.body,
),
const SizedBox(height: 6),
Text(
'Update terakhir: ${child.latestMeasurementAt ?? '-'}',
style: AppTypography.caption.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: SgColors.textSecondary,
),
],
),
),
),
const SizedBox(height: 8),
HealthCard(
color: const Color(0xFFF1FAF4),
borderColor: SgColors.secondary,
child: Column(
children: [
const Text('Punya anak lain?', style: AppTypography.h3),
const SizedBox(height: 12),
PrimaryButton(
label: 'Tambah Anak',
icon: Icons.add_rounded,
onPressed: () => Navigator.of(
context,
).push(fadeRoute(const AddChildScreen())),
),
],
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../app_design.dart';
import '../app_state.dart';
import '../services/api_service.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({
super.key,
required this.initialName,
required this.initialPhone,
this.initialEmail = '',
});
final String initialName;
final String initialPhone;
final String initialEmail;
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _api = ApiService();
late final TextEditingController _nameController;
late final TextEditingController _phoneController;
late final TextEditingController _emailController;
final _formKey = GlobalKey<FormState>();
bool _saving = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_phoneController = TextEditingController(text: widget.initialPhone);
_emailController = TextEditingController(text: widget.initialEmail);
}
@override
void dispose() {
_nameController.dispose();
_phoneController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
Map<String, dynamic> updatedProfile;
try {
updatedProfile = await _api.updateProfile(
name: _nameController.text.trim(),
phone: _phoneController.text.trim(),
email: _emailController.text.trim(),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal menyimpan: $e')),
);
return;
}
if (!mounted) return;
setState(() => _saving = false);
SgiziAppState.instance.setProfileData(updatedProfile);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Perubahan profil berhasil disimpan.')),
);
Navigator.of(context).pop(updatedProfile);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
title: const Text('Edit Profile'),
backgroundColor: const Color(0xFFF5F7F6),
),
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HealthCard(
child: Center(
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 98,
height: 98,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF0B7A86), width: 2),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: ClipOval(
child: Image.asset(
'assets/image/onboarding_consultation.png',
fit: BoxFit.cover,
),
),
),
),
Positioned(
right: -2,
bottom: -2,
child: InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Upload foto akan tersedia di versi berikutnya.')),
);
},
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFF0B7A86),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
),
),
),
],
).animate(onPlay: (c) => c.repeat(reverse: true)).scale(
begin: const Offset(0.99, 0.99),
end: const Offset(1.02, 1.02),
duration: 1700.ms,
),
const SizedBox(height: 10),
Text(
'Ubah Foto Profile',
style: AppTypography.caption.copyWith(
color: const Color(0xFF0B7A86),
fontWeight: FontWeight.w700,
),
),
],
),
),
),
const SizedBox(height: 14),
_InputField(
controller: _nameController,
label: 'Nama Lengkap',
icon: LucideIcons.user,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Nama wajib diisi.' : null,
),
const SizedBox(height: 12),
_InputField(
controller: _phoneController,
label: 'Nomor Telepon',
icon: LucideIcons.messageCircle,
validator: (v) => (v == null || v.trim().length < 8) ? 'Nomor telepon tidak valid.' : null,
),
const SizedBox(height: 12),
_InputField(
controller: _emailController,
label: 'Email',
icon: LucideIcons.info,
validator: (v) {
final value = (v ?? '').trim();
if (value.isEmpty) return null;
return value.contains('@') ? null : 'Format email tidak valid.';
},
),
const SizedBox(height: 18),
Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0B7A86), Color(0xFF1597A4)],
),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: const Color(0xFF0B7A86).withValues(alpha: 0.30),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
child: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Simpan Perubahan',
style: AppTypography.h3.copyWith(color: Colors.white),
),
const SizedBox(width: 8),
const Icon(LucideIcons.arrowRight, color: Colors.white, size: 18),
],
),
),
),
],
),
),
),
),
);
}
}
class _InputField extends StatelessWidget {
const _InputField({
required this.controller,
required this.label,
required this.icon,
this.validator,
});
final TextEditingController controller;
final String label;
final IconData icon;
final String? Function(String?)? validator;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: const Color(0xFF0B7A86)),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE3EAE8)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE3EAE8)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF0B7A86), width: 1.6),
),
),
);
}
}

View File

@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app_design.dart';
class HelpScreen extends StatefulWidget {
const HelpScreen({super.key});
@override
State<HelpScreen> createState() => _HelpScreenState();
}
class _HelpScreenState extends State<HelpScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F6),
appBar: AppBar(
title: const Text('Bantuan'),
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(
'Pusat bantuan dan informasi aplikasi S-Gizi.',
style: AppTypography.body.copyWith(color: SgColors.textPrimary),
),
const SizedBox(height: 14),
const Text('FAQ', style: AppTypography.h2),
const SizedBox(height: 10),
const _FaqCard(
question: 'Bagaimana cara menghitung status gizi anak?',
answer:
'Masuk ke menu Hitung Gizi, isi data berat, tinggi, usia, lalu simpan untuk melihat hasil analisis WHO.',
),
const SizedBox(height: 8),
const _FaqCard(
question: 'Bagaimana cara membaca hasil analisis?',
answer:
'Lihat bagian status gabungan, z-score, dan rekomendasi menu untuk memahami kondisi gizi terbaru anak.',
),
const SizedBox(height: 8),
const _FaqCard(
question: 'Bagaimana cara konsultasi dengan ahli?',
answer:
'Buka tab Ahli, pilih topik konsultasi, lalu kirim pertanyaan Anda melalui fitur chat.',
),
const SizedBox(height: 16),
const Text('Hubungi Admin', style: AppTypography.h2),
const SizedBox(height: 10),
HealthCard(
child: Column(
children: [
_ContactButton(
icon: LucideIcons.messageCircle,
title: 'WhatsApp Admin',
subtitle: '081249583765',
onTap: () => _launch(context, 'https://wa.me/6281249583765'),
),
const SizedBox(height: 8),
_ContactButton(
icon: LucideIcons.info,
title: 'Email',
subtitle: 'smartgiziapp@gmail.com',
onTap: () => _launch(context, 'mailto:smartgiziapp@gmail.com'),
),
const SizedBox(height: 8),
_ContactButton(
icon: LucideIcons.user,
title: 'Telepon',
subtitle: '081249583765',
onTap: () => _launch(context, 'tel:081249583765'),
),
],
),
),
],
),
).animate().fadeIn(duration: 220.ms).slideY(begin: 0.02, end: 0),
),
);
}
}
class _FaqCard extends StatefulWidget {
const _FaqCard({required this.question, required this.answer});
final String question;
final String answer;
@override
State<_FaqCard> createState() => _FaqCardState();
}
class _FaqCardState extends State<_FaqCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return HealthCard(
child: Column(
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
children: [
Expanded(
child: Text(
widget.question,
style: AppTypography.h3,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
AnimatedRotation(
turns: _expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
child: const Icon(LucideIcons.chevronRight),
),
],
),
),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(widget.answer, style: AppTypography.body),
),
crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 220),
),
],
),
);
}
}
class _ContactButton extends StatelessWidget {
const _ContactButton({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF7FAF9),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2EAE7)),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF7FD6C2).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF0B7A86), size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.h3),
Text(
subtitle,
style: AppTypography.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(LucideIcons.chevronRight, color: SgColors.textSecondary),
],
),
),
);
}
}
Future<void> _launch(BuildContext context, String url) async {
final uri = Uri.parse(url);
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tidak dapat membuka tautan.')),
);
}
}

View File

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

View File

@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../app_state.dart';
import 'loading_screen.dart';
class InputScreen extends StatefulWidget {
const InputScreen({super.key});
@override
State<InputScreen> createState() => _InputScreenState();
}
class _InputScreenState extends State<InputScreen> {
final _formKey = GlobalKey<FormState>();
final _birthDateController = TextEditingController();
final _measurementDateController = TextEditingController();
final _weightController = TextEditingController();
final _heightController = TextEditingController();
String _jenisKelamin = 'L';
String _caraUkur = 'standing';
DateTime? _birthDate;
DateTime? _measurementDate;
@override
void dispose() {
_birthDateController.dispose();
_measurementDateController.dispose();
_weightController.dispose();
_heightController.dispose();
super.dispose();
}
void _submit() {
FocusScope.of(context).unfocus();
if (!_formKey.currentState!.validate()) return;
final measurementDate = _measurementDate;
final childId = SgiziAppState.instance.activeChildId;
if (measurementDate == null || childId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anak aktif dan tanggal ukur wajib dipilih.'),
),
);
return;
}
Navigator.of(context).push(
fadeRoute(
LoadingScreen(
payload: {
'child_id': childId,
'tanggal_ukur': _formatDateApi(measurementDate),
'berat_badan': _parseNumber(_weightController.text),
'tinggi_badan': _parseNumber(_heightController.text),
'cara_ukur': _caraUkur,
},
),
),
);
}
double _parseNumber(String input) {
return double.parse(input.trim().replaceAll(',', '.'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(title: const Text('Input Data Gizi')),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 112),
children: [
Row(
children: [
Text(
'LANGKAH 1 DARI 2',
style: AppTypography.caption.copyWith(
color: SgColors.primary,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
const Spacer(),
Text(
'Informasi Fisik',
style: AppTypography.caption.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: const LinearProgressIndicator(
value: 0.5,
minHeight: 8,
backgroundColor: Color(0xFFE9EEEC),
valueColor: AlwaysStoppedAnimation(SgColors.primary),
),
),
const SizedBox(height: 24),
HealthCard(
child: AnimatedBuilder(
animation: SgiziAppState.instance,
builder: (context, _) {
final child = SgiziAppState.instance.activeChild;
return Row(
children: [
const CircleAvatar(
radius: 26,
backgroundColor: Color(0xFFE9F6F2),
child: Icon(
Icons.child_care_rounded,
color: SgColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Anak Aktif',
style: AppTypography.caption,
),
Text(
child?.nama ?? 'Belum ada anak aktif',
style: AppTypography.h2,
),
Text(
child?.tanggalLahir ?? '-',
style: AppTypography.body,
),
],
),
),
],
);
},
),
),
const SizedBox(height: 24),
Text(
'Tanggal Pengukuran',
style: AppTypography.h3.copyWith(fontSize: 16),
),
const SizedBox(height: 8),
_buildDateField(
controller: _measurementDateController,
label: 'Pilih tanggal',
onTap: _pickTanggalUkur,
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _buildNumberCard(
controller: _weightController,
label: 'Berat Badan',
unit: 'kg',
icon: Icons.monitor_weight_outlined,
hint: '0.0',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildNumberCard(
controller: _heightController,
label: 'Tinggi Badan',
unit: 'cm',
icon: Icons.straighten_rounded,
hint: '0.0',
),
),
],
),
const SizedBox(height: 24),
Text(
'Cara Pengukuran Tinggi',
style: AppTypography.h3.copyWith(fontSize: 16),
),
const SizedBox(height: 12),
_MeasureToggle(
value: _caraUkur,
onChanged: (value) => setState(() => _caraUkur = value),
),
const SizedBox(height: 24),
HealthCard(
color: const Color(0xFFEFF9F8),
borderColor: const Color(0xFFD8F0ED),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 22,
backgroundColor: Color(0xFFD7F0EF),
child: Icon(
Icons.info_outline_rounded,
color: SgColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Tips Akurasi:', style: AppTypography.h3),
SizedBox(height: 6),
Text(
'Gunakan timbangan digital dan pastikan anak tidak menggunakan alas kaki saat pengukuran tinggi badan.',
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)),
),
child: SafeArea(
top: false,
child: PrimaryButton(
label: 'Hitung Sekarang',
icon: Icons.chevron_right_rounded,
onPressed: _submit,
),
),
),
);
}
Widget _buildDateField({
required TextEditingController controller,
required String label,
required VoidCallback onTap,
}) {
return TextFormField(
controller: controller,
readOnly: true,
style: AppTypography.h3.copyWith(fontSize: 16),
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_month_outlined),
),
validator: (value) =>
(value ?? '').trim().isEmpty ? '$label wajib diisi.' : null,
onTap: onTap,
);
}
Widget _buildNumberCard({
required TextEditingController controller,
required String label,
required String unit,
required IconData icon,
required String hint,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.h3),
const SizedBox(height: 10),
TextFormField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: AppTypography.h2.copyWith(fontSize: 20),
decoration: InputDecoration(
prefixText: '$unit ',
hintText: hint,
suffixIcon: Icon(icon, size: 20),
),
validator: (value) => _validatePositiveNumber(value, label: label),
),
],
);
}
String? _validatePositiveNumber(String? value, {required String label}) {
final text = value?.trim() ?? '';
if (text.isEmpty) return '$label wajib diisi.';
final number = double.tryParse(text.replaceAll(',', '.'));
if (number == null) return '$label harus berupa angka.';
if (number <= 0) return '$label harus lebih dari 0.';
return null;
}
Future<void> _pickTanggalLahir() async {
final now = DateTime.now();
final initialDate =
_birthDate ?? DateTime(now.year - 3, now.month, now.day);
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(now.year - 6),
lastDate: now,
);
if (pickedDate == null) return;
setState(() {
_birthDate = pickedDate;
_birthDateController.text = _formatDateDisplay(pickedDate);
});
}
Future<void> _pickTanggalUkur() async {
final now = DateTime.now();
final initialDate = _measurementDate ?? now;
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 1),
);
if (pickedDate == null) return;
setState(() {
_measurementDate = pickedDate;
_measurementDateController.text = _formatDateDisplay(pickedDate);
});
}
String _formatDateDisplay(DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year.toString();
return '$year-$month-$day';
}
String _formatDateApi(DateTime date) => _formatDateDisplay(date);
}
class _MeasureToggle extends StatelessWidget {
const _MeasureToggle({required this.value, required this.onChanged});
final String value;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFFEDEEEE),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
_ToggleItem(
selected: value == 'standing',
label: 'Berdiri',
icon: Icons.accessible_forward_rounded,
onTap: () => onChanged('standing'),
),
_ToggleItem(
selected: value == 'lying',
label: 'Terlentang',
icon: Icons.bed_outlined,
onTap: () => onChanged('lying'),
),
],
),
);
}
}
class _ToggleItem extends StatelessWidget {
const _ToggleItem({
required this.selected,
required this.label,
required this.icon,
required this.onTap,
});
final bool selected;
final String label;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: selected ? SgColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(18),
boxShadow: selected
? [
BoxShadow(
color: SgColors.primary.withValues(alpha: 0.22),
blurRadius: 12,
offset: const Offset(0, 6),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: selected ? Colors.white : SgColors.textSecondary,
size: 20,
),
const SizedBox(width: 8),
Text(
label,
style: AppTypography.h3.copyWith(
color: selected ? Colors.white : SgColors.textSecondary,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,177 @@
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,
),
);
},
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,428 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import '../app_design.dart';
import 'auth_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _controller = PageController();
int _index = 0;
Timer? _autoSlideTimer;
@override
void initState() {
super.initState();
_autoSlideTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (!mounted || !_controller.hasClients) return;
final nextIndex = (_index + 1) % 3;
_controller.animateToPage(
nextIndex,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutCubic,
);
});
}
@override
void dispose() {
_autoSlideTimer?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const pages = [
_OnboardingPage(
imagePath: 'assets/image/onboarding_monitoring.png',
title: 'Monitoring Pertumbuhan Si Kecil Secara Akurat',
subtitle:
'Pantau perkembangan buah hati Anda dengan standar WHO Z-Score.',
floatingTags: [
_FloatingTag(
label: 'Status Normal',
icon: BootstrapIcons.heart,
align: Alignment(0.92, -0.48),
),
_FloatingTag(
label: 'Pertumbuhan +2.4 cm',
icon: LucideIcons.lineChart,
align: Alignment(-0.70, 0.24),
),
_FloatingTag(
label: 'Anak Aktif',
icon: PhosphorIconsFill.baby,
align: Alignment(-0.75, -0.52),
),
],
emphasizedWord: 'Akurat',
),
_OnboardingPage(
imagePath: 'assets/image/onboarding_food.png',
title: 'Rekomendasi Makanan Personal',
subtitle:
'Dapatkan saran menu bergizi sesuai kebutuhan tumbuh kembang si kecil.',
floatingTags: [
_FloatingTag(
label: 'Protein',
icon: PhosphorIconsFill.barbell,
align: Alignment(-0.74, -0.36),
),
_FloatingTag(
label: 'Menu MPASI',
icon: BootstrapIcons.egg,
align: Alignment(0.76, -0.70),
),
_FloatingTag(
label: 'Vitamin',
icon: LucideIcons.apple,
align: Alignment(-0.78, 0.28),
),
],
emphasizedWord: 'Personal',
),
_OnboardingPage(
imagePath: 'assets/image/onboarding_consultation.png',
title: 'Konsultasi Ahli Gizi',
subtitle:
'Diskusikan tumbuh kembang anak langsung dengan ahli gizi terpercaya.',
floatingTags: [
_FloatingTag(
label: 'Terverifikasi',
icon: PhosphorIconsFill.shieldCheck,
align: Alignment(0.72, 0.28),
),
_FloatingTag(
label: 'Chat Konsultasi',
icon: LucideIcons.messageCircle,
align: Alignment(0.86, -0.42),
),
_FloatingTag(
label: 'Ahli Gizi',
icon: PhosphorIconsFill.stethoscope,
align: Alignment(-0.74, 0.40),
),
],
emphasizedWord: 'Ahli Gizi',
),
];
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF7FCFB), Color(0xFFF1F7F6), SgColors.background],
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
child: PageView(
controller: _controller,
onPageChanged: (value) => setState(() => _index = value),
children: pages,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
pages.length,
(i) => AnimatedContainer(
duration: const Duration(milliseconds: 260),
width: i == _index ? 32 : 10,
height: 10,
margin: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
gradient: i == _index
? const LinearGradient(
colors: [SgColors.primaryDark, SgColors.primary],
)
: null,
color: i == _index ? null : const Color(0xFFCAE8E2),
borderRadius: BorderRadius.circular(99),
),
),
),
),
const SizedBox(height: 20),
_AnimatedCtaButton(
label: 'Mulai Sekarang',
onPressed: () => _enterApp(context),
),
const SizedBox(height: 14),
Wrap(
alignment: WrapAlignment.center,
children: [
Text(
'Sudah punya akun? ',
style: AppTypography.body.copyWith(
color: const Color(0xFF62707B),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _enterApp(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
'Masuk di sini',
style: AppTypography.body.copyWith(
color: SgColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: SgColors.primary,
),
),
),
),
],
),
],
),
),
),
),
),
);
}
void _enterApp(BuildContext context) {
Navigator.of(context).pushReplacement(fadeRoute(const AuthScreen()));
}
}
class _OnboardingPage extends StatelessWidget {
const _OnboardingPage({
required this.imagePath,
required this.title,
required this.subtitle,
required this.floatingTags,
this.emphasizedWord,
});
final String imagePath;
final String title;
final String subtitle;
final List<_FloatingTag> floatingTags;
final String? emphasizedWord;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 20),
_HeroImageWithBadges(
imagePath: imagePath,
floatingTags: const [],
),
const SizedBox(height: 34),
Text.rich(
_buildTitleSpan(),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
).animate().fadeIn(duration: 380.ms).slideY(begin: 0.2, end: 0),
const SizedBox(height: 14),
Text(
subtitle,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: AppTypography.body.copyWith(
fontSize: 16,
color: const Color(0xFF5A6875),
height: 1.5,
),
).animate().fadeIn(delay: 120.ms, duration: 430.ms),
const SizedBox(height: 18),
],
),
);
}
TextSpan _buildTitleSpan() {
if (emphasizedWord == null || !title.contains(emphasizedWord!)) {
return TextSpan(
text: title,
style: AppTypography.h1.copyWith(fontSize: 44, height: 1.15),
);
}
final word = emphasizedWord!;
final split = title.split(word);
return TextSpan(
style: AppTypography.h1.copyWith(fontSize: 44, height: 1.15),
children: [
TextSpan(text: split.first),
TextSpan(
text: word,
style: AppTypography.h1.copyWith(
fontSize: 44,
color: SgColors.primaryDark,
fontStyle: FontStyle.italic,
),
),
if (split.length > 1) TextSpan(text: split.last),
],
);
}
}
class _HeroImageWithBadges extends StatelessWidget {
const _HeroImageWithBadges({
required this.imagePath,
required this.floatingTags,
});
final String imagePath;
final List<_FloatingTag> floatingTags;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1.18,
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 14),
),
],
),
child: Image.asset(
imagePath,
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFE7F4F1),
alignment: Alignment.center,
child: const Icon(
Icons.broken_image_outlined,
color: SgColors.primary,
size: 42,
),
),
),
),
),
);
}
}
class _AnimatedCtaButton extends StatefulWidget {
const _AnimatedCtaButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
State<_AnimatedCtaButton> createState() => _AnimatedCtaButtonState();
}
class _AnimatedCtaButtonState extends State<_AnimatedCtaButton> {
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.97 : 1,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF147D89), Color(0xFF2BA6A7)],
),
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
color: const Color(0xFF137B87).withValues(alpha: 0.26),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22),
child: Row(
children: [
Expanded(
child: Text(
widget.label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.h2.copyWith(color: Colors.white),
),
),
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.20),
),
child: const Icon(
LucideIcons.arrowRight,
color: Colors.white,
),
),
],
),
),
),
),
),
),
);
}
}
class _FloatingTag {
const _FloatingTag({
required this.label,
required this.icon,
required this.align,
});
final String label;
final IconData icon;
final Alignment align;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,537 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../models/api_result_model.dart';
import '../models/recommendation_response_model.dart';
import '../services/api_service.dart';
import '../utils/nutrition_display_utils.dart';
class RecommendationScreen extends StatefulWidget {
const RecommendationScreen({
super.key,
this.status,
this.childId,
this.riwayatId,
this.childName,
this.measuredAt,
});
final String? status;
final int? childId;
final int? riwayatId;
final String? childName;
final String? measuredAt;
@override
State<RecommendationScreen> createState() => _RecommendationScreenState();
}
class _RecommendationScreenState extends State<RecommendationScreen> {
final ApiService _apiService = ApiService();
late Future<_RecommendationViewData> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<_RecommendationViewData> _load() async {
final requestedStatus = widget.status?.trim() ?? '';
if (requestedStatus.isEmpty &&
widget.childId == null &&
widget.riwayatId == null) {
return _RecommendationViewData.empty(
childName: widget.childName,
measuredAt: widget.measuredAt,
);
}
final response = await _apiService.getRecommendations(
status: requestedStatus,
childId: widget.childId,
riwayatId: widget.riwayatId,
);
return _RecommendationViewData.fromResponse(
response,
fallbackChildName: widget.childName,
fallbackMeasuredAt: widget.measuredAt,
);
}
void _retry() {
setState(() => _future = _load());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(
title: const Row(
children: [
AppLogo(size: 38),
SizedBox(width: 12),
Expanded(child: Text('Rekomendasi Makanan')),
],
),
),
body: FutureBuilder<_RecommendationViewData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _RecommendationSkeleton();
}
if (snapshot.hasError) {
return ErrorState(
message:
'Rekomendasi makanan belum dapat dimuat. Coba lagi dalam beberapa saat.',
onRetry: _retry,
);
}
final data = snapshot.data!;
final normalized = normalizeStatus(data.status ?? 'Normal');
final visual = nutritionStatusVisual(data.status ?? 'Normal');
if ((data.status == null || data.status!.isEmpty) &&
data.items.isEmpty) {
return EmptyState(
title: 'Belum Ada Pengukuran',
message:
'Tambahkan pengukuran terbaru agar rekomendasi makanan bisa disesuaikan dengan status gizi terakhir.',
actionLabel: 'Muat Ulang',
onAction: _retry,
);
}
if (data.items.isEmpty) {
return EmptyState(
title: 'Rekomendasi Belum Tersedia',
message:
'Belum ada menu yang terhubung ke status ${data.status}. Tambahkan data makanan di server lalu coba lagi.',
actionLabel: 'Muat Ulang',
onAction: _retry,
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 28),
children: [
HealthCard(
color: const Color(0xFFEAF7F7),
borderColor: const Color(0xFFCBEAEA),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'STATUS TERAKHIR',
style: AppTypography.caption.copyWith(
color: SgColors.primary,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
),
const SizedBox(height: 10),
Text(
data.status ?? '-',
style: AppTypography.h2,
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
StatusBadge(
text: normalized.primaryCategory,
color: visual.color,
),
...normalized.categories
.where((category) => category != normalized.primaryCategory)
.map(
(category) => StatusBadge(
text: category,
color: SgColors.primaryDark,
),
),
],
),
if (data.measuredAt != null && data.measuredAt!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Tanggal Pengukuran: ${formatMeasurementDate(data.measuredAt!)}',
style: AppTypography.body.copyWith(
color: SgColors.textPrimary,
),
),
],
if (data.childName != null && data.childName!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Nama Anak: ${data.childName!}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.body.copyWith(
color: SgColors.textPrimary,
),
),
],
const SizedBox(height: 12),
Text(
recommendationStatusExplanation(data.status ?? 'Normal'),
style: AppTypography.body,
),
const SizedBox(height: 8),
Text(normalized.focusSummary, style: AppTypography.body),
],
),
),
const SizedBox(height: 20),
Text(
'MENAMPILKAN ${data.items.length} MENU',
style: AppTypography.caption.copyWith(
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 20),
...data.items.asMap().entries.map(
(entry) => _FoodCard(
item: entry.value,
imageUrl: _imageForStatus(data.status ?? 'Normal', entry.key),
),
),
],
);
},
),
);
}
}
class _RecommendationViewData {
const _RecommendationViewData({
required this.items,
required this.status,
required this.childName,
required this.measuredAt,
});
factory _RecommendationViewData.fromResponse(
RecommendationResponseModel response, {
String? fallbackChildName,
String? fallbackMeasuredAt,
}) {
return _RecommendationViewData(
items: response.items,
status: response.resolvedStatus,
childName:
(response.measurement?.childName.trim().isNotEmpty ?? false)
? response.measurement?.childName
: fallbackChildName,
measuredAt: response.measurement?.tanggalUkur ?? fallbackMeasuredAt,
);
}
factory _RecommendationViewData.empty({
String? childName,
String? measuredAt,
}) {
return _RecommendationViewData(
items: const [],
status: null,
childName: childName,
measuredAt: measuredAt,
);
}
final List<RekomendasiModel> items;
final String? status;
final String? childName;
final String? measuredAt;
}
class _FoodCard extends StatelessWidget {
const _FoodCard({required this.item, required this.imageUrl});
final RekomendasiModel item;
final String imageUrl;
@override
Widget build(BuildContext context) {
return HealthCard(
margin: const EdgeInsets.only(bottom: 20),
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: AspectRatio(
aspectRatio: 16 / 8,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFDDEFE8),
child: const Icon(
Icons.restaurant_rounded,
size: 44,
color: SgColors.primary,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.black.withValues(alpha: 0.46),
],
),
),
),
Positioned(
left: 16,
top: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${item.kalori} KKAL',
style: AppTypography.caption.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Text(
item.menu,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h2.copyWith(color: Colors.white),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 10,
children: [
_NutrientChip(
icon: Icons.local_fire_department_outlined,
label: '${item.kalori} kkal',
),
_NutrientChip(
icon: Icons.egg_alt_outlined,
label: '${item.protein} g protein',
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFEFF9F8),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFD8F0ED)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 5),
decoration: const BoxDecoration(
color: SgColors.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MENGAPA COCOK?',
style: AppTypography.caption.copyWith(
color: SgColors.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(item.alasan, style: AppTypography.body),
],
),
),
],
),
),
],
),
),
],
),
);
}
}
class _NutrientChip extends StatelessWidget {
const _NutrientChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF7FAF9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SgColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: SgColors.primaryDark),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _RecommendationSkeleton extends StatelessWidget {
const _RecommendationSkeleton();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(24),
children: [
HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 180, height: 12, color: const Color(0xFFE9EEEC)),
const SizedBox(height: 12),
Container(width: 140, height: 28, color: const Color(0xFFE9EEEC)),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: 12,
color: const Color(0xFFE9EEEC),
),
],
),
),
const SizedBox(height: 20),
for (var i = 0; i < 2; i++) ...[
HealthCard(
padding: EdgeInsets.zero,
child: Column(
children: [
Container(height: 140, color: const Color(0xFFE9EEEC)),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
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),
],
],
);
}
}
String _imageForStatus(String status, int index) {
final normalized = normalizeStatus(status);
if (normalized.hasStunting) {
return _stuntingImages[index % _stuntingImages.length];
}
if (normalized.hasUnderweight || normalized.hasWasting) {
return _underweightImages[index % _underweightImages.length];
}
if (normalized.hasObesitas) {
return _balancedImages[index % _balancedImages.length];
}
return _normalImages[index % _normalImages.length];
}
const _stuntingImages = [
'https://images.unsplash.com/photo-1515003197210-e0cd71810b5f?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-1512621776951-a57141f2eefd?auto=format&fit=crop&w=900&q=80',
];
const _underweightImages = [
'https://images.unsplash.com/photo-1511690743698-d9d85f2fbf38?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1467003909585-2f8a72700288?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?auto=format&fit=crop&w=900&q=80',
];
const _balancedImages = [
'https://images.unsplash.com/photo-1498837167922-ddd27525d352?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1547592180-85f173990554?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1490645935967-10de6ba17061?auto=format&fit=crop&w=900&q=80',
];
const _normalImages = [
'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1505253716362-afaea1d3d1af?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1512058564366-18510be2db19?auto=format&fit=crop&w=900&q=80',
];

View File

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

View File

@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import '../app_design.dart';
import '../models/riwayat_response_model.dart';
import '../utils/nutrition_display_utils.dart';
import 'consultation_chat_screen.dart';
import 'recommendation_screen.dart';
class RiwayatDetailScreen extends StatelessWidget {
const RiwayatDetailScreen({
super.key,
required this.child,
required this.item,
});
final ChildInfoModel child;
final RiwayatItemModel item;
@override
Widget build(BuildContext context) {
final visual = nutritionStatusVisual(item.statusGabungan);
final bbuCategory = bbuCategoryFromScore(
item.zBbu,
fallback: item.kategori.bbu,
);
final tbuCategory = tbuCategoryFromScore(
item.zTbu,
fallback: item.kategori.tbu,
);
final bbtbCategory = bbtbCategoryFromScore(
item.zBbtb,
fallback: item.kategori.bbtb,
);
return Scaffold(
backgroundColor: SgColors.background,
appBar: AppBar(title: const Text('Detail Analisis')),
body: ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 160),
children: [
HealthCard(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'STATUS UTAMA',
style: AppTypography.caption.copyWith(
color: SgColors.primary,
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 24,
backgroundColor: visual.color.withValues(alpha: 0.14),
child: Icon(visual.icon, color: visual.color, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status Gizi: ${item.statusGabungan}',
style: AppTypography.h2,
),
const SizedBox(height: 10),
StatusBadge(
text: visual.badgeLabel,
color: visual.color,
),
],
),
),
],
),
const SizedBox(height: 18),
const Divider(height: 1, color: SgColors.border),
const SizedBox(height: 18),
Row(
children: [
ChildAvatar(
name: child.nama,
gender: child.jenisKelamin,
photoUrl: child.photoUrl,
radius: 28,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
child.nama,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
const SizedBox(height: 4),
Text(
formatAgeFromMonths(item.umurBulan),
style: AppTypography.caption,
),
const SizedBox(height: 2),
Text(
formatMeasurementDate(item.tanggalUkur),
style: AppTypography.caption,
),
],
),
),
],
),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: 'Data Pengukuran',
child: Column(
children: [
_InfoRow(label: 'Berat', value: '${item.berat.toStringAsFixed(1)} kg'),
_InfoRow(label: 'Tinggi', value: '${item.tinggi.toStringAsFixed(1)} cm'),
_InfoRow(
label: 'Cara',
value: measurementMethodLabel(item.caraUkur, item.umurBulan),
),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: 'Detail Z-Score',
child: Column(
children: [
_ScoreRow(
label: 'BB/U',
score: formatScore(item.zBbu),
category: bbuCategory,
),
_ScoreRow(
label: 'TB/U',
score: formatScore(item.zTbu),
category: tbuCategory,
),
_ScoreRow(
label: 'BB/TB',
score: formatScore(item.zBbtb),
category: bbtbCategory,
),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: 'Interpretasi',
child: Text(
buildInterpretation(
status: item.statusGabungan,
bbuCategory: bbuCategory,
tbuCategory: tbuCategory,
bbtbCategory: bbtbCategory,
),
style: AppTypography.body.copyWith(color: SgColors.textPrimary),
),
),
],
),
bottomSheet: Container(
padding: const EdgeInsets.fromLTRB(24, 14, 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: [
PrimaryButton(
label: 'Lihat Rekomendasi',
icon: Icons.restaurant_menu_rounded,
onPressed: () {
Navigator.of(context).push(
fadeRoute(
RecommendationScreen(
status: item.statusGabungan,
childId: child.id,
riwayatId: item.id,
childName: child.nama,
measuredAt: item.tanggalUkur,
),
),
);
},
),
const SizedBox(height: 12),
PrimaryButton(
label: 'Konsultasi',
icon: Icons.chat_bubble_outline_rounded,
isOutlined: true,
onPressed: () {
Navigator.of(
context,
).push(fadeRoute(const ConsultationChatScreen()));
},
),
],
),
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title.toUpperCase(), style: AppTypography.h3),
const SizedBox(height: 16),
child,
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
SizedBox(width: 72, child: Text(label, style: AppTypography.body)),
Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
),
],
),
);
}
}
class _ScoreRow extends StatelessWidget {
const _ScoreRow({
required this.label,
required this.score,
required this.category,
});
final String label;
final String score;
final String category;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
SizedBox(width: 64, child: Text(label, style: AppTypography.body)),
Expanded(
child: Text(
'$score -> $category',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.h3,
),
),
],
),
);
}
}

View File

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

View File

@ -0,0 +1,235 @@
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)));
}

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