This commit is contained in:
Dwi Swandhana
2026-08-20 05:54:23 +07:00
parent 45dad70390
commit de5f13a417
165 changed files with 17 additions and 25903 deletions
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Response;
use ZipArchive;
use JsonMachine\Items;
use Illuminate\Http\Request;
use Log;
class JsonTransferController extends Controller
{
protected $exportPath = 'database/seeders/data/';
@@ -148,14 +148,26 @@ class JsonTransferController extends Controller
return back()->with('error', "Error 500 Details: " . $e->getMessage());
}
}
public function pull(Request $request){
public function pull(Request $request)
{
if (session('previlage') !== 'developer') {
abort(403, 'Akses ditolak. Anda tidak memiliki izin untuk menjalankan git pull.');
Log::warning('Unauthorized pull attempt by user: ' . (auth()->check() ? auth()->user()->email : 'Guest') . ' at ' . now());
$output = "Unauthorized access. You do not have permission to perform this action.";
}
$gitPath = base_path();
$output = shell_exec("cd {$gitPath} && git pull 2>&1");
$command = "cd {$gitPath} && " .
"git fetch origin 2>&1 && " .
"git reset --hard origin/main 2>&1 && " .
"git clean -fd 2>&1 && " .
"php artisan config:cache 2>&1 && " .
"php artisan optimize:clear 2>&1";
$output = shell_exec($command);
if (auth()->check()) {
Log::info('Git pull executed by user: ' . auth()->user()->email.' at '.now());
Log::info('Force pull & optimize executed by user: ' . auth()->user()->email . ' at ' . now());
}
return back()->with('git_output', $output);
-3266
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
blinker==1.9.0
click==8.1.8
Flask==3.1.2
greenlet==3.2.4
hl7==0.4.5
importlib_metadata==8.7.0
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
psycopg2-binary==2.9.10
pyserial==3.5
SQLAlchemy==2.0.43
typing_extensions==4.14.1
Werkzeug==3.1.3
zipp==3.23.0
-45
View File
@@ -1,45 +0,0 @@
# 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
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/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
-45
View File
@@ -1,45 +0,0 @@
# 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: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: ios
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: linux
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: macos
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: web
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: windows
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# 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'
-224
View File
@@ -1,224 +0,0 @@
# Mikrobiology Laboratory Information System (MyLIS)
MyLIS Flutter adalah aplikasi pendamping untuk Laravel LIS lokal milik Rumah Sakit Umum Daerah Dr. Saiful Anwar. Aplikasi ini fokus pada akses mobile/desktop untuk login, dashboard Early Warning Sistem, daftar pemeriksaan, detail pemeriksaan, dan pengisian expertise per template DLP.
Nama panjang aplikasi:
```text
Mikrobiology Laboratory Information System (MyLIS)
```
Pemilik aplikasi:
```text
Rumah Sakit Umum Daerah Dr. Saiful Anwar
```
Default URL Laravel lokal:
```text
https://lis.swandhana.test/
```
URL tersebut bisa diganti dari halaman sebelum login. Tersedia tombol `Ping` agar pengguna dapat memastikan server Laravel sudah dapat diakses sebelum login.
## Fitur Utama
- Login tanpa register.
- Pengaturan URL Laravel dari layar login.
- Ping koneksi ke Laravel.
- Dashboard dengan menu utama `Early Warning Sistem`.
- Daftar pemeriksaan per buku/kategori.
- Detail pemeriksaan dengan status dan tombol `Expertise`.
- Screen expertise Flutter native, tidak membuka Blade Laravel.
- Pemilihan template saat `periksa.dlp` masih kosong.
- Penyimpanan draft, preliminary, final, dan kirim ke SPV sesuai role pengguna.
- Dukungan build Android, iOS, dan macOS.
## Alur Expertise
Jika `periksa.dlp` kosong, aplikasi menampilkan pilihan template. Jika sudah terisi, aplikasi langsung membuka wizard sesuai DLP.
Screen DLP yang sudah dibuat:
- `CCI`
- `Kultur`
- `Pewarna Langsung`
- `TBC`
- `Viral Load`
- `IgM IgG Leptospira`
- `PCR COVID`
Semua wizard memakai pola umum:
1. Data pasien dan petugas.
2. Isian parameter/template sesuai DLP.
3. Tes Kepekaan Antibiotik jika berlaku.
4. Data alat jika berlaku.
5. Isian expertise akhir.
Navigasi wizard tidak memakai validasi wajib, sehingga pengguna bebas `Next` dan `Previous`.
## Role dan Tombol Simpan
Untuk role supervisor/SPV:
- `Save as Draft`
- `Save Preliminary result`
- `Save Final Result`
Untuk role selain supervisor/SPV:
- `Save as Draft`
- `Save and Send To SPV`
- `Kirim SPV Preliminary`
Checkbox `Nilai Kritis` tersedia di step akhir dan dikirim ke API saat menyimpan.
## Struktur Folder
```text
lib/
app/
app.dart
data/
expertise_data.dart
providers/
session_provider.dart
screens/
dashboard/
examinations/
expertise/
login/
services/
api_client.dart
session_store.dart
widgets/
common_widgets.dart
main.dart
```
Ringkasan:
- `main.dart`: entry point aplikasi.
- `app/app.dart`: root aplikasi dan registrasi part screen.
- `services/api_client.dart`: komunikasi HTTP ke Laravel.
- `services/session_store.dart`: penyimpanan token dan base URL.
- `providers/session_provider.dart`: pengecekan session awal.
- `screens/login`: halaman login, URL Laravel, dan ping.
- `screens/dashboard`: dashboard dan Early Warning Sistem.
- `screens/examinations`: daftar dan detail pemeriksaan.
- `screens/expertise`: semua screen wizard DLP.
- `widgets/common_widgets.dart`: komponen UI umum.
- `data/expertise_data.dart`: daftar DLP dan label template.
## API Laravel
Aplikasi memakai endpoint mobile Laravel dengan prefix:
```text
/api/mobile
```
Endpoint utama:
- `GET /api/mobile/ping`
- `POST /api/mobile/login`
- `GET /api/mobile/dashboard`
- `GET /api/mobile/early-warning`
- `GET /api/mobile/books/{master}/examinations`
- `GET /api/mobile/examinations/{id}`
- `GET /api/mobile/examinations/{id}/expertise`
- `POST /api/mobile/examinations/{id}/expertise/template`
- `POST /api/mobile/examinations/{id}/expertise`
Controller Laravel berada di:
```text
/Users/duidev/htdocs/lis/htdocs/app/Http/Controllers/MobileApiController.php
```
Route API berada di:
```text
/Users/duidev/htdocs/lis/htdocs/routes/api.php
```
## Menjalankan Aplikasi
Ambil dependency:
```bash
flutter pub get
```
Jalankan di device/simulator:
```bash
flutter run
```
Jalankan dengan default URL lain:
```bash
flutter run --dart-define=LIS_BASE_URL=https://lis.swandhana.test/
```
## Verifikasi
Analisis kode Flutter:
```bash
flutter analyze
```
Test Flutter:
```bash
flutter test
```
Cek route Laravel mobile:
```bash
php artisan route:list --path=mobile
```
Cek sintaks controller Laravel:
```bash
php -l app/Http/Controllers/MobileApiController.php
```
## Build
Android debug:
```bash
flutter build apk --debug
```
iOS debug tanpa code signing:
```bash
flutter build ios --debug --no-codesign
```
macOS debug:
```bash
flutter build macos --debug
```
Output build yang umum:
- Android: `build/app/outputs/flutter-apk/app-debug.apk`
- iOS: `build/ios/iphoneos/Runner.app`
- macOS: `build/macos/Build/Products/Debug/mylis.app`
## Catatan Lokal
- Pastikan Laravel lokal `https://lis.swandhana.test/` aktif.
- Pastikan sertifikat lokal dipercaya oleh device/simulator yang dipakai.
- Untuk iOS device fisik, build `--no-codesign` tetap perlu signing manual sebelum deploy.
- Jangan menjalankan beberapa command Flutter berat secara bersamaan karena bisa berebut file sementara pada folder platform.
-28
View File
@@ -1,28 +0,0 @@
# 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
View File
@@ -1,14 +0,0 @@
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
-64
View File
@@ -1,64 +0,0 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties =
Properties().apply {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
android {
namespace = "com.duidev.mylis"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.duidev.mylis"
// 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
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = (keystoreProperties["storeFile"] as String?)?.let {
file(it)
}
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
-29
View File
@@ -1,29 +0,0 @@
{
"project_info": {
"project_number": "732589737888",
"project_id": "mylis-a4983",
"storage_bucket": "mylis-a4983.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:732589737888:android:3f782e07e62ace55a446c5",
"android_client_info": {
"package_name": "com.duidev.mylis"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAibwJgxVnkVOOIGsjbf2SpW_isJOFTtAg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -1,7 +0,0 @@
<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>
@@ -1,47 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="MyLIS"
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>
@@ -1,5 +0,0 @@
package com.duidev.mylis
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -1,12 +0,0 @@
<?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>
@@ -1,12 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -1,18 +0,0 @@
<?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>
@@ -1,18 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<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>
-24
View File
@@ -1,24 +0,0 @@
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)
}
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
-26
View File
@@ -1,26 +0,0 @@
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 "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

-34
View File
@@ -1,34 +0,0 @@
**/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
-24
View File
@@ -1,24 +0,0 @@
<?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>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-647
View File
@@ -1,647 +0,0 @@
// !$*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 */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
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>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
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 = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
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 */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.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;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
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;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
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 */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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)";
DEVELOPMENT_TEAM = R8C84SJUAF;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
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.duidev.mylis.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.duidev.mylis.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.duidev.mylis.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)";
DEVELOPMENT_TEAM = R8C84SJUAF;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
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)";
DEVELOPMENT_TEAM = R8C84SJUAF;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
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 */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?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>
@@ -1,8 +0,0 @@
<?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>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<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>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?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>
@@ -1,8 +0,0 @@
<?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>
-16
View File
@@ -1,16 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
<?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>
@@ -1,26 +0,0 @@
<?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>
-72
View File
@@ -1,72 +0,0 @@
<?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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MyLIS</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>MyLIS</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>NSCameraUsageDescription</key>
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</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>
</dict>
</plist>
@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"
-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
-12
View File
@@ -1,12 +0,0 @@
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.
}
}
-68
View File
@@ -1,68 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
part '../providers/session_provider.dart';
part '../services/api_client.dart';
part '../services/session_store.dart';
part '../screens/login/login_screen.dart';
part '../screens/dashboard/dashboard_screen.dart';
part '../screens/dashboard/critical_values_screen.dart';
part '../screens/dashboard/barcode_scanner_screen.dart';
part '../screens/dashboard/ews_screen.dart';
part '../screens/dashboard/sample_groups_screen.dart';
part '../screens/dashboard/specimen_register_screen.dart';
part '../screens/dashboard/initial_work_screen.dart';
part '../screens/examinations/examination_list_screen.dart';
part '../screens/examinations/examination_detail_screen.dart';
part '../screens/expertise/expertise_screen.dart';
part '../screens/expertise/template_picker.dart';
part '../screens/expertise/cci_expertise_wizard.dart';
part '../screens/expertise/kultur_expertise_wizard.dart';
part '../screens/expertise/pewarnaan_langsung_expertise_wizard.dart';
part '../screens/expertise/viral_load_expertise_wizard.dart';
part '../screens/expertise/leptospira_expertise_wizard.dart';
part '../screens/expertise/covid_expertise_wizard.dart';
part '../screens/expertise/tbc_expertise_wizard.dart';
part '../screens/expertise/expertise_form.dart';
part '../data/expertise_data.dart';
part '../widgets/common_widgets.dart';
const String kAppShortName = 'MyLIS';
const String kAppLongName = 'Mikrobiology Laboratory Information System';
const String kHospitalName = 'Rumah Sakit Umum Daerah Dr. Saiful Anwar';
const String kAppLogoAsset = 'assets/branding/logo.png';
const String kHospitalLogoAsset = 'assets/branding/logo_rssa.png';
const String kDefaultBaseUrl = String.fromEnvironment(
'LIS_BASE_URL',
defaultValue: 'http://10.10.123.218',
);
class MyLisApp extends StatelessWidget {
const MyLisApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: kAppLongName,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF7FAF9),
useMaterial3: true,
),
home: const SessionGate(),
);
}
}
-29
View File
@@ -1,29 +0,0 @@
part of '../app/app.dart';
const Set<String> _knownDlp = {
'CCI',
'Kultur',
'Pewarna Langsung',
'TBC',
'Viral Load',
'IgM IgG Leptospira',
'PCR COVID',
};
String _templateTitle(String dlp) {
return switch (dlp) {
'CCI' => 'Pemeriksaan Candida Colonization Index',
'Kultur' => 'Kultur',
'Pewarna Langsung' => 'Pewarna Langsung',
'TBC' => 'TBC',
'Viral Load' => 'Viral Load',
'IgM IgG Leptospira' => 'IgM IgG Leptospira',
'PCR COVID' => 'PCR COVID',
_ => dlp,
};
}
bool _isSupervisor(String? role) {
final normalized = role?.toLowerCase().trim() ?? '';
return normalized.contains('supervisor') || normalized == 'spv';
}
-25
View File
@@ -1,25 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'app/app.dart';
export 'app/app.dart';
void main() {
HttpOverrides.global = _InternalCertificateHttpOverrides();
runApp(const MyLisApp());
}
class _InternalCertificateHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback = (certificate, host, port) {
debugPrint(
'MyLIS menerima sertifikat internal untuk $host:$port '
'issuer=${certificate.issuer}',
);
return true;
};
}
}
-35
View File
@@ -1,35 +0,0 @@
part of '../app/app.dart';
class SessionGate extends StatefulWidget {
const SessionGate({super.key});
@override
State<SessionGate> createState() => _SessionGateState();
}
class _SessionGateState extends State<SessionGate> {
late final Future<SessionData> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = SessionStore.session();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<SessionData>(
future: _sessionFuture,
builder: (context, snapshot) {
if (!snapshot.hasData &&
snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
final session = snapshot.data ?? const SessionData();
return session.token == null
? const LoginScreen()
: DashboardScreen(token: session.token!, baseUrl: session.baseUrl);
},
);
}
}
@@ -1,169 +0,0 @@
part of '../../app/app.dart';
class BarcodeScannerScreen extends StatefulWidget {
const BarcodeScannerScreen({super.key});
@override
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
final MobileScannerController _controller = MobileScannerController();
final _manualCode = TextEditingController();
bool _handled = false;
@override
void dispose() {
_manualCode.dispose();
_controller.dispose();
super.dispose();
}
void _handleDetect(BarcodeCapture capture) {
if (_handled) {
return;
}
final value = capture.barcodes
.map((barcode) => barcode.rawValue?.trim() ?? '')
.firstWhere((text) => text.isNotEmpty, orElse: () => '');
if (value.isEmpty) {
return;
}
_handled = true;
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan Barcode Sampel'),
actions: [
IconButton(
onPressed: () => _controller.toggleTorch(),
icon: const Icon(Icons.flashlight_on_outlined),
tooltip: 'Lampu',
),
],
),
body: Stack(
children: [
MobileScanner(
controller: _controller,
onDetect: _handleDetect,
errorBuilder: (context, error) => _ScannerFallback(
controller: _manualCode,
message:
'Scanner belum tersedia. Tutup aplikasi lalu jalankan ulang full rebuild, atau masukkan barcode manual.',
onSubmit: _submitManualCode,
),
placeholderBuilder: (context) =>
const Center(child: CircularProgressIndicator()),
),
Center(
child: Container(
width: 260,
height: 180,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 3),
borderRadius: BorderRadius.circular(16),
),
),
),
Positioned(
left: 18,
right: 18,
bottom: 24,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Arahkan kamera ke barcode nomor sampel.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
void _submitManualCode() {
final code = _manualCode.text.trim();
if (code.isEmpty) {
return;
}
Navigator.of(context).pop(code);
}
}
class _ScannerFallback extends StatelessWidget {
const _ScannerFallback({
required this.controller,
required this.message,
required this.onSubmit,
});
final TextEditingController controller;
final String message;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFF7FAF9),
padding: const EdgeInsets.all(18),
child: Center(
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.qr_code_scanner_rounded,
size: 42,
color: Color(0xFF0F766E),
),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 14),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => onSubmit(),
decoration: const InputDecoration(
labelText: 'Barcode / No. Sampel',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onSubmit,
icon: const Icon(Icons.manage_search_rounded),
label: const Text('Gunakan Kode'),
),
],
),
),
),
),
);
}
}
@@ -1,567 +0,0 @@
part of '../../app/app.dart';
class CriticalValuesScreen extends StatefulWidget {
const CriticalValuesScreen({
super.key,
required this.token,
required this.baseUrl,
});
final String token;
final String baseUrl;
@override
State<CriticalValuesScreen> createState() => _CriticalValuesScreenState();
}
class _CriticalValuesScreenState extends State<CriticalValuesScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/critical-values');
}
void _reload() {
setState(() {
_future = _load();
});
}
Future<void> _markRead(Map<String, dynamic> item) async {
final method = ValueNotifier<String>('');
final recipient = TextEditingController();
final reason = TextEditingController();
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
18,
0,
18,
MediaQuery.viewInsetsOf(context).bottom + 18,
),
child: SingleChildScrollView(
child: ValueListenableBuilder<String>(
valueListenable: method,
builder: (context, value, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Tindak Lanjut Nilai Kritis',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
'${item['nofoto'] ?? '-'}${item['nmpasien'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: value.isEmpty ? null : value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Melalui',
helperText: 'Kosongkan jika belum dilaporkan',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'Telpon', child: Text('Telpon')),
DropdownMenuItem(
value: 'Whatshap',
child: Text('Whatshap'),
),
DropdownMenuItem(value: 'Email', child: Text('Email')),
DropdownMenuItem(
value: 'Ketemu di Jalan',
child: Text('Ketemu di Jalan'),
),
],
onChanged: (next) => method.value = next ?? '',
),
const SizedBox(height: 12),
TextField(
controller: recipient,
decoration: const InputDecoration(
labelText: 'Penerima Laporan',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: reason,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Alasan belum dilaporkan/Pasien meninggal',
hintText: 'Meninggal / Pindah Rumah Sakit / dll',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: () async {
try {
await _api.post(
'api/mobile/critical-values/${item['id']}/mark-read',
{
'caratindaklanjut': method.value,
'penerima_laporan': recipient.text.trim(),
'alasan_belum_laporkan': reason.text.trim(),
},
);
if (!context.mounted) return;
Navigator.of(context).pop();
_reload();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sample nilai kritis sudah ditindaklanjuti.',
),
),
);
} on ApiException catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
},
icon: const Icon(Icons.done_all_outlined),
label: const Text('Mark as Read'),
),
],
),
),
),
),
),
);
recipient.dispose();
reason.dispose();
method.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final pending = asList(data['pending_items']);
final followed = asList(data['followed_items']);
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: _CriticalValuesHeader(
pendingCount: pending.length,
onRefresh: _reload,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 0),
child: _CriticalValuesSummary(
pendingCount: pending.length,
followedCount: followed.length,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: Container(
height: 48,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12),
),
child: const TabBar(
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(9)),
),
labelColor: Color(0xFF0B7BFF),
unselectedLabelColor: Color(0xFF47517A),
labelStyle: TextStyle(fontWeight: FontWeight.w900),
tabs: [
Tab(text: 'Belum Dilaporkan'),
Tab(text: 'Riwayat'),
],
),
),
),
Expanded(
child: TabBarView(
children: [
CriticalValueList(
items: pending,
emptyText:
'Tidak ada sample nilai kritis yang perlu dilaporkan.',
onMarkRead: _markRead,
),
CriticalValueList(
items: followed,
emptyText: 'Belum ada riwayat tindak lanjut.',
),
],
),
),
],
),
);
},
),
),
);
}
}
class _CriticalValuesHeader extends StatelessWidget {
const _CriticalValuesHeader({
required this.pendingCount,
required this.onRefresh,
});
final int pendingCount;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'Nilai Kritis',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 8),
BadgeLabel(
text: '$pendingCount',
color: const Color(0xFFFF1D25),
),
],
),
const SizedBox(height: 4),
Text(
'Notifikasi & Tindak Lanjut',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: const Color(0xFF063B60),
tooltip: 'Refresh',
),
],
);
}
}
class _CriticalValuesSummary extends StatelessWidget {
const _CriticalValuesSummary({
required this.pendingCount,
required this.followedCount,
});
final int pendingCount;
final int followedCount;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Expanded(
child: _CriticalSummaryItem(
label: 'Belum Dilaporkan',
value: pendingCount,
color: const Color(0xFFFF1D25),
),
),
Container(width: 1, height: 48, color: const Color(0xFFE1E8F5)),
Expanded(
child: _CriticalSummaryItem(
label: 'Riwayat',
value: followedCount,
color: const Color(0xFF0B7BFF),
),
),
],
),
);
}
}
class _CriticalSummaryItem extends StatelessWidget {
const _CriticalSummaryItem({
required this.label,
required this.value,
required this.color,
});
final String label;
final int value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
'$value',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: color,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
],
);
}
}
class CriticalValueList extends StatelessWidget {
const CriticalValueList({
super.key,
required this.items,
required this.emptyText,
this.onMarkRead,
});
final List<dynamic> items;
final String emptyText;
final ValueChanged<Map<String, dynamic>>? onMarkRead;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return EmptyPanel(text: emptyText);
}
return RefreshIndicator(
onRefresh: () async {},
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = asMap(items[index]);
return CriticalValueCard(
item: item,
index: index,
onMarkRead: onMarkRead == null ? null : () => onMarkRead!(item),
);
},
),
);
}
}
class CriticalValueCard extends StatelessWidget {
const CriticalValueCard({
super.key,
required this.item,
required this.index,
this.onMarkRead,
});
final Map<String, dynamic> item;
final int index;
final VoidCallback? onMarkRead;
@override
Widget build(BuildContext context) {
final followed = item['followed_up_at']?.toString().isNotEmpty == true;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: const Color(
0xFFFF1D25,
).withValues(alpha: 0.12),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Color(0xFFFF1D25),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 3),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
StatusPill(
status: followed
? item['tat_status_label']?.toString() ?? '-'
: 'Belum Dilaporkan',
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
BadgeLabel(
text: item['asalpasien']?.toString() ?? '-',
color: const Color(0xFF0F766E),
),
BadgeLabel(
text: item['nm_spesimen']?.toString() ?? '-',
color: const Color(0xFF2563EB),
),
],
),
const SizedBox(height: 12),
DetailRow(
label: 'Waktu Set Nilai Kritis',
value: _timeWithUser(
item['critical_set_at'],
item['critical_set_by_name'],
),
),
if (followed) ...[
DetailRow(
label: 'Waktu Ditindaklanjuti',
value: _timeWithUser(
item['followed_up_at'],
item['followed_up_by_name'],
),
),
DetailRow(
label: 'Selisih Waktu',
value: item['tat_duration_label'],
),
DetailRow(label: 'Aksi', value: _followUpLabel(item)),
],
if (onMarkRead != null)
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: onMarkRead,
icon: const Icon(Icons.done_all_outlined),
label: const Text('Mark as Read'),
),
),
],
),
),
);
}
String _timeWithUser(Object? time, Object? user) {
final first = time?.toString().isNotEmpty == true ? time.toString() : '-';
final second = user?.toString().isNotEmpty == true ? '\noleh $user' : '';
return '$first$second';
}
String _followUpLabel(Map<String, dynamic> item) {
final status = item['follow_up_status']?.toString() ?? '';
if (status == 'reported') {
return 'Dilaporkan\nMelalui: ${item['follow_up_method'] ?? '-'}\nPenerima: ${item['follow_up_recipient'] ?? '-'}';
}
if (status == 'not_reported') {
return 'Belum Dilaporkan\nAlasan: ${item['follow_up_reason'] ?? '-'}';
}
return '-';
}
}
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
part of '../../app/app.dart';
class EwsScreen extends StatelessWidget {
const EwsScreen({
super.key,
required this.warnings,
required this.token,
required this.baseUrl,
});
final List<dynamic> warnings;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
final total = warnings.fold<int>(
0,
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
);
return Scaffold(
appBar: AppBar(title: const Text('Early Warning Sistem')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
SectionTitle(
title: 'Early Warning Sistem',
actionLabel: '$total kasus',
),
if (warnings.isEmpty)
const EmptyPanel(text: 'Tidak ada data Early Warning.')
else
...warnings.map(
(group) => EarlyWarningGroupCard(
group: asMap(group),
token: token,
baseUrl: baseUrl,
),
),
],
),
);
}
}
@@ -1,878 +0,0 @@
part of '../../app/app.dart';
class InitialWorkScreen extends StatefulWidget {
const InitialWorkScreen({
super.key,
required this.token,
required this.baseUrl,
required this.user,
required this.notificationCount,
});
final String token;
final String baseUrl;
final Map<String, dynamic> user;
final int notificationCount;
@override
State<InitialWorkScreen> createState() => _InitialWorkScreenState();
}
class _InitialWorkScreenState extends State<InitialWorkScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
static const int _pageSize = 10;
int _page = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
_search.addListener(_onSearchChanged);
}
@override
void dispose() {
_search.removeListener(_onSearchChanged);
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/initial-work/samples');
}
void _reload() {
setState(() {
_future = _load();
});
}
void _onSearchChanged() {
if (!mounted) return;
setState(() {
_page = 0;
});
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
}
void _showProfile() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(widget.user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${widget.user['username'] ?? '-'} - ${widget.user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
Future<void> _showActions(Map<String, dynamic> item) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(
'${item['nmpasien'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
style: const TextStyle(color: Color(0xFF47517A)),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showAcceptDialog(item);
},
icon: const Icon(Icons.check_circle_outline_rounded),
label: const Text('Terima'),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_confirmReject(item);
},
icon: const Icon(Icons.block_rounded),
label: const Text('Tolak'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFDC2626),
),
),
],
),
),
),
);
}
Future<void> _showAcceptDialog(Map<String, dynamic> item) async {
await showDialog<void>(
context: context,
builder: (context) => _AcceptInitialWorkDialog(
item: item,
onSubmit: (payload) => _accept(item, payload),
),
);
}
Future<bool> _accept(
Map<String, dynamic> item,
Map<String, dynamic> payload,
) async {
final id = (item['id'] as num?)?.toInt();
if (id == null) return false;
try {
final response = await _api.post(
'api/mobile/initial-work/samples/$id/accept',
payload,
);
if (!mounted) return false;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['message']?.toString() ?? 'Sampel diterima.'),
),
);
_reload();
return true;
} on ApiException catch (error) {
if (!mounted) return false;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
return false;
}
}
Future<void> _confirmReject(Map<String, dynamic> item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tolak Sampel'),
content: Text('Kembalikan ${item['nofoto'] ?? 'sampel ini'} ke loket?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
),
child: const Text('Tolak'),
),
],
),
);
if (confirmed != true) return;
await _reject(item);
}
Future<void> _reject(Map<String, dynamic> item) async {
final id = (item['id'] as num?)?.toInt();
if (id == null) return;
try {
final response = await _api.post(
'api/mobile/initial-work/samples/$id/reject',
const {},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['message']?.toString() ?? 'Sampel ditolak.'),
),
);
_reload();
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
}
List<Map<String, dynamic>> _filtered(List<dynamic> items) {
final query = _search.text.trim().toLowerCase();
final mapped = items.map(asMap).toList();
if (query.isEmpty) {
return mapped;
}
return mapped.where((item) {
final haystack = [
item['nofoto'],
item['noregister'],
item['nmpasien'],
item['reques'],
item['status'],
item['nm_spesimen'],
].whereType<Object>().join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final items = _filtered(asList(snapshot.data?['items']));
final maxPage = items.isEmpty ? 0 : (items.length - 1) ~/ _pageSize;
final page = _page.clamp(0, maxPage);
final start = items.isEmpty ? 0 : page * _pageSize;
final end = items.isEmpty
? 0
: (start + _pageSize).clamp(0, items.length);
final visible = items.sublist(start, end);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
children: [
_InitialWorkHeader(
total: items.length,
user: widget.user,
notificationCount: widget.notificationCount,
onNotifications: _openCriticalValues,
onProfile: _showProfile,
),
const SizedBox(height: 22),
TextField(
controller: _search,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Cari No. Sampel, No. RM, atau Nama Pasien',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: Text(
'Sampel dengan status Penerimaan Sampel',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
BadgeLabel(
text: '${items.length} sampel',
color: const Color(0xFF0B7BFF),
),
],
),
const SizedBox(height: 14),
if (snapshot.connectionState != ConnectionState.done)
const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
)
else if (snapshot.hasError)
ErrorView(message: _message(snapshot.error), onRetry: _reload)
else if (items.isEmpty)
const EmptyPanel(text: 'Tidak ada sampel penerimaan.')
else ...[
...visible.map(
(item) => _InitialWorkCard(
item: item,
onTap: () => _showActions(item),
),
),
const SizedBox(height: 16),
_InitialWorkPager(
start: start + 1,
end: end,
total: items.length,
canPrevious: page > 0,
canNext: page < maxPage,
onPrevious: () => setState(() => _page = page - 1),
onNext: () => setState(() => _page = page + 1),
),
],
],
);
},
),
),
);
}
}
class _InitialWorkHeader extends StatelessWidget {
const _InitialWorkHeader({
required this.total,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final int total;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'Pengerjaan Awal',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
const SizedBox(width: 8),
BadgeLabel(text: '$total', color: const Color(0xFF0B7BFF)),
],
),
const SizedBox(height: 4),
Text(
'Penerimaan Sampel',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(28),
onTap: onProfile,
child: CircleAvatar(
radius: 27,
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _InitialWorkCard extends StatelessWidget {
const _InitialWorkCard({required this.item, required this.onTap});
final Map<String, dynamic> item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final dateTime = _splitDateTime(item['daftar']?.toString() ?? '');
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: item['status']?.toString() ?? 'Penerimaan Sampel',
color: const Color(0xFF0B7BFF),
),
const SizedBox(height: 12),
_CardMeta(text: dateTime.$1),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'No. RM',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
item['noregister']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 14),
const Text(
'Nama',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
_CardMeta(text: dateTime.$2),
],
),
),
],
),
),
),
);
}
(String, String) _splitDateTime(String value) {
if (value.isEmpty) {
return ('-', '-');
}
final normalized = value.replaceFirst('T', ' ');
final parts = normalized.split(RegExp(r'\s+'));
final date = parts.isNotEmpty ? parts.first : '-';
final time = parts.length > 1 ? parts[1].split('.').first : '-';
return (date, time);
}
}
class _CardMeta extends StatelessWidget {
const _CardMeta({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
fontSize: 13,
),
);
}
}
class _InitialWorkPager extends StatelessWidget {
const _InitialWorkPager({
required this.start,
required this.end,
required this.total,
required this.canPrevious,
required this.canNext,
required this.onPrevious,
required this.onNext,
});
final int start;
final int end;
final int total;
final bool canPrevious;
final bool canNext;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'$start-$end dari $total sampel',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
),
const Text(
'Tampilkan',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 10),
BadgeLabel(text: '10', color: const Color(0xFF667095)),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canPrevious ? onPrevious : null,
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canNext ? onNext : null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
);
}
}
class _AcceptInitialWorkDialog extends StatefulWidget {
const _AcceptInitialWorkDialog({required this.item, required this.onSubmit});
final Map<String, dynamic> item;
final Future<bool> Function(Map<String, dynamic> payload) onSubmit;
@override
State<_AcceptInitialWorkDialog> createState() =>
_AcceptInitialWorkDialogState();
}
class _AcceptInitialWorkDialogState extends State<_AcceptInitialWorkDialog> {
static const _mediaOptions = [
'-',
'Media BAP',
'Media CAP',
'Media Mc Conkey',
'Media SDA R1',
'Media SDA R2',
'Media SDA I1',
'Media SDA I2',
];
final Set<String> _selectedMedia = {};
String _jenis = '';
String _bulan = '';
bool _saving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Terima Sampel'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item['nofoto']?.toString() ?? '-',
style: const TextStyle(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(widget.item['nmpasien']?.toString() ?? '-'),
const SizedBox(height: 18),
Text(
'Media Tanam Yang digunakan',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _mediaOptions.map((media) {
final selected = _selectedMedia.contains(media);
return FilterChip(
label: Text(media == '-' ? 'Tidak Menggunakan Media' : media),
selected: selected,
onSelected: (_) => setState(() {
if (selected) {
_selectedMedia.remove(media);
} else {
_selectedMedia.add(media);
}
}),
);
}).toList(),
),
const SizedBox(height: 18),
Text(
'Khusus Yang Akan dikirim ke BD MGIT',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF667095),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: _jenis,
decoration: const InputDecoration(
labelText: 'Jenis Pengobatan Terduga/Pasien TBC',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: '', child: Text('Pilih Salah Satu')),
DropdownMenuItem(value: 'F', child: Text('Follow Up')),
DropdownMenuItem(value: 'K', child: Text('Kontrol Bulan Ke')),
DropdownMenuItem(
value: 'P',
child: Text('Pasca Pengobatan Bulan Ke'),
),
],
onChanged: (value) => setState(() {
_jenis = value ?? '';
if (_jenis == 'F' || _jenis.isEmpty) {
_bulan = '';
}
}),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _bulan,
decoration: const InputDecoration(
labelText: 'Bulan Ke',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(
value: '',
child: Text('Pilih Salah Satu'),
),
...List.generate(
24,
(index) => DropdownMenuItem(
value: '${index + 1}',
child: Text('Bulan Ke - ${index + 1}'),
),
),
],
onChanged: _jenis == 'K' || _jenis == 'P'
? (value) => setState(() => _bulan = value ?? '')
: null,
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Terima'),
),
],
);
}
Future<void> _submit() async {
if (_selectedMedia.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Media Tanam Yang digunakan wajib dipilih.'),
),
);
return;
}
if ((_jenis == 'K' || _jenis == 'P') && _bulan.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Jika bukan Follow Up, Bulan Ke wajib diisi.'),
),
);
return;
}
setState(() => _saving = true);
final saved = await widget.onSubmit({
'mediatanam': _selectedMedia.toList(),
'jenis': _jenis,
'bulan': _bulan,
});
if (!mounted) return;
if (saved) {
Navigator.of(context).pop();
} else {
setState(() => _saving = false);
}
}
}
@@ -1,421 +0,0 @@
part of '../../app/app.dart';
class _DashboardSubHeader extends StatelessWidget {
const _DashboardSubHeader({
required this.title,
required this.subtitle,
required this.badgeText,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final String title;
final String subtitle;
final String badgeText;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 8),
BadgeLabel(text: badgeText, color: const Color(0xFF0B7BFF)),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(28),
onTap: onProfile,
child: CircleAvatar(
radius: 27,
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class SampleGroupsScreen extends StatelessWidget {
const SampleGroupsScreen({
super.key,
required this.books,
required this.token,
required this.baseUrl,
required this.user,
required this.notificationCount,
});
final List<dynamic> books;
final String token;
final String baseUrl;
final Map<String, dynamic> user;
final int notificationCount;
@override
Widget build(BuildContext context) {
final total = books.fold<int>(
0,
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
);
return Scaffold(
body: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
_DashboardSubHeader(
title: 'Detail Sampel',
subtitle: 'Catatan & Register',
badgeText: '$total',
user: user,
notificationCount: notificationCount,
onNotifications: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: token, baseUrl: baseUrl),
),
);
},
onProfile: () => _showProfile(context, user),
),
const SizedBox(height: 28),
_SampleGroupsSummary(total: total, count: books.length),
const SizedBox(height: 18),
Text(
'Jenis Spesimen',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 12),
if (books.isEmpty)
const EmptyPanel(text: 'Tidak ada kelompok pemeriksaan.')
else
...books.map(
(book) => _SampleGroupCard(
book: asMap(book),
onTap: () {
final mapped = asMap(book);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SpecimenRegisterScreen(
token: token,
baseUrl: baseUrl,
master: mapped['master']?.toString() ?? 'buku0',
title: mapped['label']?.toString() ?? 'Spesimen',
total: (mapped['total'] as num? ?? 0).toInt(),
user: user,
notificationCount: notificationCount,
),
),
);
},
),
),
],
),
);
}
void _showProfile(BuildContext context, Map<String, dynamic> user) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${user['username'] ?? '-'} - ${user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _SampleGroupsSummary extends StatelessWidget {
const _SampleGroupsSummary({required this.total, required this.count});
final int total;
final int count;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: const Color(0xFFFF6B00).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.menu_book_rounded,
color: Color(0xFFFF6B00),
size: 32,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$total sampel aktif',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
'$count jenis spesimen tersedia',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
}
}
class _SampleGroupCard extends StatelessWidget {
const _SampleGroupCard({required this.book, required this.onTap});
final Map<String, dynamic> book;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final total = (book['total'] as num? ?? 0).toInt();
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: const Color(0xFFFF6B00).withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: const Icon(
Icons.science_outlined,
color: Color(0xFFFF6B00),
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book['label']?.toString() ?? 'Spesimen',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: '${book['master'] ?? '-'}',
color: const Color(0xFFFF6B00),
),
],
),
),
BadgeLabel(text: '$total sampel', color: const Color(0xFF0B7BFF)),
const SizedBox(width: 8),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)),
],
),
),
),
);
}
}
@@ -1,765 +0,0 @@
part of '../../app/app.dart';
class SpecimenRegisterScreen extends StatefulWidget {
const SpecimenRegisterScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
required this.total,
required this.user,
required this.notificationCount,
});
final String token;
final String baseUrl;
final String master;
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
@override
State<SpecimenRegisterScreen> createState() => _SpecimenRegisterScreenState();
}
class _SpecimenRegisterScreenState extends State<SpecimenRegisterScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
static const _allSpecimens = '__all_specimens__';
static const _allStatuses = '__all_statuses__';
static const int _pageSize = 10;
String _specimen = _allSpecimens;
String _status = _allStatuses;
int _page = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
_search.addListener(_onSearchChanged);
}
@override
void dispose() {
_search.removeListener(_onSearchChanged);
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/books/${widget.master}/examinations');
}
void _reload() {
setState(() {
_page = 0;
_future = _load();
});
}
void _onSearchChanged() {
if (!mounted) return;
setState(() {
_page = 0;
});
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
}
void _showProfile() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(widget.user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${widget.user['username'] ?? '-'}${widget.user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final items = asList(snapshot.data?['items']);
final filtered = _filtered(items);
final specimens = _specimenOptions(items);
final statuses = _statusOptions(items);
final maxPage = filtered.isEmpty
? 0
: (filtered.length - 1) ~/ _pageSize;
final page = _page.clamp(0, maxPage);
final start = filtered.isEmpty ? 0 : page * _pageSize;
final end = filtered.isEmpty
? 0
: (start + _pageSize).clamp(0, filtered.length);
final visible = filtered.sublist(start, end);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
children: [
_SpecimenHeader(
title: widget.title,
total: filtered.length,
user: widget.user,
notificationCount: widget.notificationCount,
onNotifications: _openCriticalValues,
onProfile: _showProfile,
),
const SizedBox(height: 24),
_FilterDropdown(
label: 'Jenis Spesimen',
value: _specimen,
icon: Icons.science_outlined,
options: specimens,
onChanged: (value) => setState(() {
_specimen = value;
_status = _allStatuses;
_page = 0;
}),
),
const SizedBox(height: 18),
_FilterDropdown(
label: 'Status Pengerjaan',
value: _status,
icon: Icons.assignment_outlined,
options: statuses,
onChanged: (value) => setState(() {
_status = value;
_page = 0;
}),
),
const SizedBox(height: 22),
Text(
'Cari Spesimen',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 10),
_SearchBlock(search: _search),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 18),
_RegisterTitle(title: widget.title, total: filtered.length),
const SizedBox(height: 14),
if (snapshot.connectionState != ConnectionState.done)
const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
)
else if (snapshot.hasError)
ErrorView(message: _message(snapshot.error), onRetry: _reload)
else if (filtered.isEmpty)
const EmptyPanel(text: 'Tidak ada sampel.')
else ...[
...visible.map(
(item) => SpecimenSampleCard(
item: asMap(item),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
const SizedBox(height: 16),
_SpecimenPager(
start: start + 1,
end: end,
total: filtered.length,
canPrevious: page > 0,
canNext: page < maxPage,
onPrevious: () => setState(() => _page = page - 1),
onNext: () => setState(() => _page = page + 1),
),
],
],
);
},
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: 1,
onDestinationSelected: (index) {
if (index == 0) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
if (index == 2) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
label: 'Beranda',
),
NavigationDestination(icon: Icon(Icons.search), label: 'Cari Sampel'),
NavigationDestination(
icon: Icon(Icons.person_outline),
label: 'Profil',
),
],
),
);
}
List<dynamic> _filtered(List<dynamic> items) {
final query = _search.text.trim().toLowerCase();
return items.where((raw) {
final item = asMap(raw);
final specimenValue = _specimenValue(item);
final specimenOk =
_specimen == _allSpecimens || specimenValue == _specimen;
final statusOk =
_status == _allStatuses || item['status']?.toString() == _status;
final searchOk =
query.isEmpty ||
[
item['nofoto'],
item['noregister'],
item['nmpasien'],
item['reques'],
item['kd_spesimen'],
item['nm_spesimen'],
item['status'],
].whereType<Object>().join(' ').toLowerCase().contains(query);
return specimenOk && statusOk && searchOk;
}).toList();
}
List<_FilterOption> _specimenOptions(List<dynamic> items) {
final values = <String, String>{};
for (final raw in items) {
final item = asMap(raw);
final value = _specimenValue(item);
final label = item['nm_spesimen']?.toString() ?? '';
if (value.isNotEmpty && label.isNotEmpty) {
values.putIfAbsent(value, () => label);
}
}
final options =
values.entries
.map((entry) => _FilterOption(value: entry.key, label: entry.value))
.toList()
..sort((a, b) => a.label.compareTo(b.label));
return [
const _FilterOption(value: _allSpecimens, label: 'Semua Spesimen'),
...options,
];
}
List<_FilterOption> _statusOptions(List<dynamic> items) {
final filteredBySpecimen = items.where((raw) {
final item = asMap(raw);
return _specimen == _allSpecimens || _specimenValue(item) == _specimen;
});
final values =
filteredBySpecimen
.map((item) => asMap(item)['status']?.toString() ?? '')
.where((value) => value.isNotEmpty)
.toSet()
.toList()
..sort();
return [
const _FilterOption(value: _allStatuses, label: 'Semua Status'),
...values.map((value) => _FilterOption(value: value, label: value)),
];
}
String _specimenValue(Map<String, dynamic> item) {
final code = item['kd_spesimen']?.toString() ?? '';
if (code.isNotEmpty) {
return code;
}
return item['nm_spesimen']?.toString() ?? '';
}
}
class _FilterOption {
const _FilterOption({required this.value, required this.label});
final String value;
final String label;
}
class _SpecimenHeader extends StatelessWidget {
const _SpecimenHeader({
required this.title,
required this.total,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
const SizedBox(width: 10),
BadgeLabel(text: '$total', color: const Color(0xFF6D28D9)),
],
),
const SizedBox(height: 4),
Text(
'Catatan & Register',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 14),
InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onProfile,
child: CircleAvatar(
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.icon,
required this.options,
required this.onChanged,
});
final String label;
final String value;
final IconData icon;
final List<_FilterOption> options;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final values = options.map((option) => option.value).toSet();
final effectiveValue = values.contains(value) ? value : options.first.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: effectiveValue,
isExpanded: true,
selectedItemBuilder: (context) => options
.map(
(option) => Align(
alignment: Alignment.centerLeft,
child: Text(
option.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
decoration: InputDecoration(
prefixIcon: Icon(icon, color: const Color(0xFF6D28D9)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFA875FF)),
),
),
items: options
.map(
(option) => DropdownMenuItem(
value: option.value,
child: Text(option.label, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: (value) => onChanged(value ?? options.first.value),
),
],
);
}
}
class _SearchBlock extends StatelessWidget {
const _SearchBlock({required this.search});
final TextEditingController search;
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: search,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Ketik No. Sampel, No. RM, atau Nama Pasien',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: BadgeLabel(
text: 'Pencarian: No. Sampel / No. RM / Nama',
color: const Color(0xFF6D28D9),
),
),
],
);
}
}
class _SpecimenPager extends StatelessWidget {
const _SpecimenPager({
required this.start,
required this.end,
required this.total,
required this.canPrevious,
required this.canNext,
required this.onPrevious,
required this.onNext,
});
final int start;
final int end;
final int total;
final bool canPrevious;
final bool canNext;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'$start-$end dari $total sampel',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
),
const Text(
'Tampilkan',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 10),
BadgeLabel(text: '10', color: const Color(0xFF667095)),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canPrevious ? onPrevious : null,
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canNext ? onNext : null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
);
}
}
class _RegisterTitle extends StatelessWidget {
const _RegisterTitle({required this.title, required this.total});
final String title;
final int total;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.menu_book_rounded, color: Color(0xFF6D28D9)),
const SizedBox(width: 10),
Expanded(
child: Text(
'BUKU REGISTER ${title.toUpperCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF6D28D9),
fontWeight: FontWeight.w900,
),
),
),
Text(
'$total',
style: const TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w900,
),
),
],
);
}
}
class SpecimenSampleCard extends StatelessWidget {
const SpecimenSampleCard({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: item['reques']?.toString() ?? '-',
color: const Color(0xFF0891B2),
),
const SizedBox(height: 8),
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
Text(
'No.RM ${item['noregister'] ?? '-'}',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Container(width: 1, height: 96, color: const Color(0xFFE1E8F5)),
const SizedBox(width: 14),
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Status :',
style: TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
),
],
),
),
),
);
}
}
@@ -1,277 +0,0 @@
part of '../../app/app.dart';
class ExaminationDetailScreen extends StatefulWidget {
const ExaminationDetailScreen({
super.key,
required this.token,
required this.baseUrl,
required this.id,
});
final String token;
final String baseUrl;
final int id;
@override
State<ExaminationDetailScreen> createState() =>
_ExaminationDetailScreenState();
}
class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/examinations/${widget.id}');
}
void _reload() {
setState(() {
_future = _api.get('api/mobile/examinations/${widget.id}');
});
}
Future<void> _launch(String? url) async {
if (url == null || url.isEmpty) {
return;
}
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Halaman tidak bisa dibuka.')),
);
}
}
Future<void> _openExpertise() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExpertiseScreen(
token: widget.token,
baseUrl: widget.baseUrl,
id: widget.id,
),
),
);
_reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final item = asMap(data['item']);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
children: [
SafeArea(
bottom: false,
child: _ExaminationScreenHeader(
title: 'Detail Pemeriksaan',
subtitle: item['nofoto']?.toString() ?? '-',
onRefresh: _reload,
),
),
const SizedBox(height: 22),
_ExaminationPatientCard(item: item),
const SizedBox(height: 14),
_ExaminationInfoCard(item: item),
const SizedBox(height: 14),
_ExaminationActionPanel(
onExpertise: _openExpertise,
onRefresh: _reload,
resultUrl: data['result_url']?.toString(),
onPreview: () => _launch(data['result_url']?.toString()),
),
],
);
},
),
);
}
}
class _ExaminationPatientCard extends StatelessWidget {
const _ExaminationPatientCard({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 10),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
BadgeLabel(
text: item['nofoto']?.toString() ?? '-',
color: const Color(0xFF0B7BFF),
),
BadgeLabel(
text: 'RM ${item['noregister'] ?? '-'}',
color: const Color(0xFF667095),
),
BadgeLabel(
text: item['nm_spesimen']?.toString() ?? '-',
color: const Color(0xFFFF6B00),
),
],
),
const SizedBox(height: 12),
Text(
item['reques']?.toString() ?? '-',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
height: 1.35,
),
),
],
),
);
}
}
class _ExaminationInfoCard extends StatelessWidget {
const _ExaminationInfoCard({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
final rows = [
('Asal Pasien', item['asalpasien']),
('Ruangan', item['ruangan']),
('Dokter Pengirim', item['klinisi'] ?? item['nmdokter']),
('Tanggal Daftar', item['daftar']),
('Tanggal Sampel', item['tanggalsampel']),
('Cara Pengambilan', item['pengambilan']),
('Asal Pengambilan', item['asalpengirim']),
('Alamat', item['alamatpasien']),
];
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informasi Pemeriksaan',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 14),
...rows.map((row) => DetailRow(label: row.$1, value: row.$2)),
],
),
);
}
}
class _ExaminationActionPanel extends StatelessWidget {
const _ExaminationActionPanel({
required this.onExpertise,
required this.onRefresh,
required this.resultUrl,
required this.onPreview,
});
final VoidCallback onExpertise;
final VoidCallback onRefresh;
final String? resultUrl;
final VoidCallback onPreview;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: onExpertise,
icon: const Icon(Icons.edit_document),
label: const Text('Expertise'),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined),
label: const Text('Cek Status'),
),
if (resultUrl?.isNotEmpty == true) ...[
const SizedBox(height: 10),
TextButton.icon(
onPressed: onPreview,
icon: const Icon(Icons.description_outlined),
label: const Text('Preview Hasil'),
),
],
],
),
);
}
}
@@ -1,357 +0,0 @@
part of '../../app/app.dart';
class ExaminationListScreen extends StatefulWidget {
const ExaminationListScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
this.initialSearch,
});
final String token;
final String baseUrl;
final String master;
final String title;
final String? initialSearch;
@override
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
}
class _ExaminationListScreenState extends State<ExaminationListScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_search.text = widget.initialSearch ?? '';
_future = _load();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
final query = _search.text.trim();
return _api.get(
'api/mobile/books/${widget.master}/examinations',
query.isEmpty ? null : {'search': query},
);
}
void _reload() {
setState(() {
_future = _load();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: SafeArea(
bottom: false,
child: _ExaminationScreenHeader(
title: widget.title,
subtitle: 'List Pemeriksaan',
onRefresh: _reload,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFCFE0FF)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _reload(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Cari no lab, RM, pasien, order',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: IconButton(
onPressed: _reload,
icon: const Icon(Icons.manage_search_rounded),
tooltip: 'Cari',
),
),
),
),
),
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final items = asList(snapshot.data!['items']);
if (items.isEmpty) {
return const EmptyPanel(text: 'Tidak ada pemeriksaan aktif.');
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
itemCount: items.length,
itemBuilder: (context, index) => ExaminationCard(
item: asMap(items[index]),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
);
},
),
),
],
),
);
}
}
class ExaminationCompactTile extends StatelessWidget {
const ExaminationCompactTile({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
title: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
subtitle: Text(
'${item['nofoto'] ?? '-'} / ${item['warning_label'] ?? item['status'] ?? '-'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: Color(0xFF667095),
),
onTap: () => _openDetail(context, item, token, baseUrl),
),
);
}
}
class ExaminationCard extends StatelessWidget {
const ExaminationCard({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
BadgeLabel(
text: item['nofoto']?.toString() ?? '-',
color: const Color(0xFF0B7BFF),
),
BadgeLabel(
text: 'RM ${item['noregister'] ?? '-'}',
color: const Color(0xFF667095),
),
],
),
const SizedBox(height: 10),
BadgeLabel(
text: item['reques']?.toString() ?? '-',
color: const Color(0xFF0891B2),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
item['daftar']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
),
TextButton.icon(
onPressed: () => _openDetail(context, item, token, baseUrl),
icon: const Icon(Icons.edit_note),
label: const Text('Expertise'),
),
],
),
],
),
),
),
);
}
}
class _ExaminationScreenHeader extends StatelessWidget {
const _ExaminationScreenHeader({
required this.title,
required this.subtitle,
required this.onRefresh,
});
final String title;
final String subtitle;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: const Color(0xFF063B60),
tooltip: 'Refresh',
),
],
);
}
}
Future<void> _openDetail(
BuildContext context,
Map<String, dynamic> item,
String token,
String baseUrl,
) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationDetailScreen(
token: token,
baseUrl: baseUrl,
id: (item['id'] as num).toInt(),
),
),
);
}
@@ -1,609 +0,0 @@
part of '../../app/app.dart';
class CciExpertiseWizard extends StatefulWidget {
const CciExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<CciExpertiseWizard> createState() => _CciExpertiseWizardState();
}
class _CciExpertiseWizardState extends State<CciExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 3);
}
@override
void didUpdateWidget(covariant CciExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 3);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 3);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: Column(
children: [
PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
],
),
),
Step(
title: const Text('CCI'),
isActive: _step == 1,
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pemeriksaan Candida Colonization Index',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
_select(
'id_sputum',
'Sputum',
_options('jsonsputum', ['Pilih Salah Satu']),
),
_select(
'id_swabtenggorok',
'Swab Tenggorok',
_options('jsonswabtenggorok', ['Pilih Salah Satu']),
),
_select(
'id_urine',
'Urine',
_options('jsonurine', ['Pilih Salah Satu']),
),
_select(
'id_swabperineum',
'Swab Perineum',
_options('jsonswabperineum', ['Pilih Salah Satu']),
),
],
),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 2,
content: ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Final'),
isActive: _step == 3,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () =>
widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
),
),
],
);
}
List<String> _options(String key, List<String> fallback) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 10),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class PatientStaffPanel extends StatelessWidget {
const PatientStaffPanel({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.staffValues,
required this.onChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, String> staffValues;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
final status = item['status']?.toString() ?? '-';
final genderAge = '${item['jkpasien'] ?? '-'}, ${item['usia'] ?? '-'}';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 24,
backgroundColor: const Color(
0xFF0F766E,
).withValues(alpha: 0.12),
child: const Icon(
Icons.person_outline,
color: Color(0xFF0F766E),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 3),
Text(
'RM ${_textValue(item['noregister'])} · Lab ${_textValue(item['nofoto'])}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.black54),
),
],
),
),
StatusPill(status: status),
],
),
const SizedBox(height: 14),
_InfoGrid(
items: [
_InfoItem('Order', item['reques'], Icons.receipt_long),
_InfoItem(
'Dokter Pengirim',
item['klinisi'] ?? item['nmdokter'],
Icons.local_hospital_outlined,
),
_InfoItem(
'Asal Pasien',
item['asalpasien'],
Icons.apartment_outlined,
),
_InfoItem('Gender, Age', genderAge, Icons.badge_outlined),
_InfoItem('Phone', item['tlppasien'], Icons.call_outlined),
_InfoItem(
'Tanggal Registrasi',
item['tanggalregis'] ?? item['daftar'],
Icons.event_available_outlined,
),
_InfoItem(
'Tanggal Pengambilan',
item['tanggalsampel'],
Icons.science_outlined,
),
_InfoItem(
'Cara Pengambilan',
item['pengambilan'],
Icons.medical_services_outlined,
),
_InfoItem(
'Asal Pengambilan',
item['asalpengirim'],
Icons.output_outlined,
),
_InfoItem(
'Spesimen',
item['kesimpulan'] ?? item['nm_spesimen'],
Icons.biotech_outlined,
),
],
),
const SizedBox(height: 8),
_InfoTile(
label: 'Address',
value: item['alamatpasien'],
icon: Icons.place_outlined,
),
],
),
),
),
const SizedBox(height: 12),
Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Petugas',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
_staffSelect('analis', 'ATLM', asList(staffOptions['analis'])),
_staffSelect('ppds3', 'PPDS', asList(staffOptions['ppds'])),
_staffSelect('dokter', 'SPV', asList(staffOptions['dokters'])),
],
),
),
),
const SizedBox(height: 12),
_InfoTile(
label: 'Klinis/Diagnosis',
value: item['klinis'],
icon: Icons.assignment_outlined,
),
],
);
}
Widget _staffSelect(String name, String label, List<dynamic> users) {
final current = staffValues[name];
final items = users.map(asMap).toList();
final hasCurrent = items.any((item) => item['id'].toString() == current);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue: hasCurrent ? current : null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.account_circle_outlined),
),
items: [
const DropdownMenuItem(value: '0', child: Text('Pilih')),
...items.map(
(item) => DropdownMenuItem(
value: item['id'].toString(),
child: Text(item['nama']?.toString() ?? '-'),
),
),
],
onChanged: (value) {
staffValues[name] = value ?? '0';
onChanged();
},
),
);
}
String _textValue(Object? value) {
final text = value?.toString() ?? '';
return text.trim().isEmpty ? '-' : decodeHtmlEntities(text.trim());
}
}
class _InfoGrid extends StatelessWidget {
const _InfoGrid({required this.items});
final List<_InfoItem> items;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth >= 520 ? 2 : 1;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final item in items)
SizedBox(
width: columns == 1
? constraints.maxWidth
: (constraints.maxWidth - 10) / 2,
child: _InfoTile(
label: item.label,
value: item.value,
icon: item.icon,
compact: true,
),
),
],
);
},
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({
required this.label,
required this.value,
required this.icon,
this.compact = false,
});
final String label;
final Object? value;
final IconData icon;
final bool compact;
@override
Widget build(BuildContext context) {
final cleanValue = _clean(value);
return Container(
padding: EdgeInsets.all(compact ? 10 : 12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: const Color(0xFF0F766E)),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.black54,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
cleanValue,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w800,
color: Colors.black87,
),
),
],
),
),
],
),
);
}
String _clean(Object? value) {
final text = value?.toString() ?? '';
return text.trim().isEmpty ? '-' : decodeHtmlEntities(text.trim());
}
}
class _InfoItem {
const _InfoItem(this.label, this.value, this.icon);
final String label;
final Object? value;
final IconData icon;
}
const List<String> _sirOptions = [
'ESBL',
'MRSA',
'MDR',
'XDR',
'PDR',
'Carbapenem Resistant',
'MDR Carbapenem Resistant',
'XDR Carbapenem Resistant',
];
const List<String> _colonyOptions = [
'>= 10^5 CFU/ml Urine (Bakteriuria bermakna)',
'>= 10^5 CFU/ml Urine (Candiduria bermakna)',
'>= 10^3 CFU/ml Urine (Bakteriuria bermakna)',
'8 x 10^3 CFU/ml Urine (Bakteriuria bermakna)',
'lainnya',
];
@@ -1,385 +0,0 @@
part of '../../app/app.dart';
class CovidExpertiseWizard extends StatefulWidget {
const CovidExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<CovidExpertiseWizard> createState() => _CovidExpertiseWizardState();
}
class _CovidExpertiseWizardState extends State<CovidExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
@override
void didUpdateWidget(covariant CovidExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 4);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 4);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < _covidVirusFields.length; i += 1)
CollapsibleExpertiseSection(
index: i + 1,
title: _covidVirusFields[i].label,
completed: _hasAny([
_covidVirusFields[i].resultField,
_covidVirusFields[i].unitField,
_covidVirusFields[i].referenceField,
]),
initiallyExpanded:
i == 0 &&
!_hasAny([
_covidVirusFields[i].resultField,
_covidVirusFields[i].unitField,
]),
children: [
_select(
_covidVirusFields[i].resultField,
_covidVirusFields[i].label,
_covidResultOptions,
),
_text(_covidVirusFields[i].unitField, 'Satuan'),
_text(_covidVirusFields[i].referenceField, 'Nilai Rujukan'),
],
),
CollapsibleExpertiseSection(
index: _covidVirusFields.length + 1,
title: 'Interpretasi',
completed: _hasAny(['covid_interpretasi']),
children: [
_text(
'covid_interpretasi',
'Interpretasi',
minLines: 6,
maxLines: 9,
),
],
),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
}
return false;
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class _CovidVirusField {
const _CovidVirusField(
this.label,
this.resultField,
this.unitField,
this.referenceField,
);
final String label;
final String resultField;
final String unitField;
final String referenceField;
}
const List<String> _covidResultOptions = [
'Positif',
'Negatif',
'Tidak Diperiksa',
];
const List<_CovidVirusField> _covidVirusFields = [
_CovidVirusField(
'Virus SARS-Cov-2',
'covid_virus01',
'covid_satuanvirus01',
'covid_rujukanvirus01',
),
_CovidVirusField(
'Influenza A',
'covid_virus02',
'covid_satuanvirus02',
'covid_rujukanvirus02',
),
_CovidVirusField(
'Influenza B',
'covid_virus03',
'covid_satuanvirus03',
'covid_rujukanvirus03',
),
_CovidVirusField(
'RSV',
'covid_virus04',
'covid_satuanvirus04',
'covid_rujukanvirus04',
),
];
File diff suppressed because it is too large Load Diff
@@ -1,557 +0,0 @@
part of '../../app/app.dart';
class ExpertiseScreen extends StatefulWidget {
const ExpertiseScreen({
super.key,
required this.token,
required this.baseUrl,
required this.id,
});
final String token;
final String baseUrl;
final int id;
@override
State<ExpertiseScreen> createState() => _ExpertiseScreenState();
}
class _ExpertiseScreenState extends State<ExpertiseScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final Map<String, TextEditingController> _textControllers = {};
final Map<String, String> _selectValues = {};
final Map<String, Set<String>> _multiValues = {};
final Map<String, String> _staffValues = {};
final Map<String, int> _wizardSteps = {};
String _currentDlp = '';
bool _criticalValue = false;
bool _saving = false;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
@override
void dispose() {
for (final controller in _textControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/examinations/${widget.id}/expertise');
}
void _reload() {
setState(() {
_future = _load();
});
}
void _prepareFields(Map<String, dynamic> data) {
final dlp = data['dlp']?.toString() ?? '';
if (_currentDlp == dlp && _textControllers.isNotEmpty) {
return;
}
_currentDlp = dlp;
for (final controller in _textControllers.values) {
controller.dispose();
}
_textControllers.clear();
_selectValues.clear();
_multiValues.clear();
_staffValues.clear();
final components = asMap(data['components']);
final item = asMap(data['item']);
final user = asMap(data['user']);
_staffValues['analis'] = (item['analis'] ?? user['id'] ?? '0').toString();
_staffValues['ppds3'] = (item['ppds3'] ?? user['id'] ?? '0').toString();
_staffValues['dokter'] = (item['dokter_id'] ?? user['id'] ?? '0')
.toString();
_criticalValue = data['is_critical'] == true;
for (final rawField in asList(data['fields'])) {
final field = asMap(rawField);
final name = field['name']?.toString() ?? '';
final type = field['type']?.toString() ?? 'text';
final value = components[name];
if (type == 'select') {
_selectValues[name] = value?.toString() ?? '';
} else if (type == 'multiselect') {
_multiValues[name] = value is List
? value.map((item) => item.toString()).toSet()
: <String>{};
} else {
_textControllers[name] = TextEditingController(
text: value?.toString() ?? '',
);
}
}
for (final name in kAntibioticMediaRowFields) {
_textControllers[name] = TextEditingController(
text: components[name]?.toString() ?? '',
);
}
for (final name in kToolAntibioticRowFields) {
_textControllers[name] = TextEditingController(
text: components[name]?.toString() ?? '',
);
}
}
Future<void> _setTemplate(String dlp) async {
setState(() => _saving = true);
try {
await _api.post(
'api/mobile/examinations/${widget.id}/expertise/template',
{'dlp': dlp},
);
if (!mounted) return;
_showMessage('Template $dlp dipilih.');
setState(() {
_future = _load();
});
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Template tidak bisa dipilih.');
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
Future<void> _save(String action) async {
if (action == 'statuspewarnaanlsg') {
_wizardSteps[_currentDlp] = 1;
}
setState(() => _saving = true);
final fields = <String, dynamic>{};
for (final entry in _textControllers.entries) {
fields[entry.key] = entry.value.text.trim();
}
for (final entry in _selectValues.entries) {
fields[entry.key] = entry.value;
}
for (final entry in _multiValues.entries) {
fields[entry.key] = entry.value.toList();
}
Map<String, dynamic> data;
try {
data = await _api.post('api/mobile/examinations/${widget.id}/expertise', {
'dlp': _currentDlp,
'action': action,
'fields': fields,
'keterangan': fields['keterangan']?.toString() ?? '',
'analis': _staffValues['analis'] ?? '0',
'ppds3': _staffValues['ppds3'] ?? '0',
'dokter': _staffValues['dokter'] ?? '0',
'nilai_kritis': _criticalValue ? '1' : '0',
});
} on ApiException catch (error) {
_showMessage(error.message);
return;
} catch (_) {
_showMessage('Expertise tidak bisa disimpan.');
return;
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
if (!mounted) return;
_showMessage(data['message']?.toString() ?? 'Expertise tersimpan.');
setState(() {
_future = _load();
});
}
int _wizardStep(String dlp) => _wizardSteps[dlp] ?? 0;
void _setWizardStep(String dlp, int step) {
_wizardSteps[dlp] = step;
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final item = asMap(data['item']);
final user = asMap(data['user']);
_prepareFields(data);
final isSupervisor = _isSupervisor(user['previlage']?.toString());
final summaryCard = _ExpertiseSummaryCard(
item: item,
dlp: _currentDlp,
);
final expertiseContent = _currentDlp.isEmpty
? TemplatePicker(
templates: asList(data['templates']),
saving: _saving,
onUse: _setTemplate,
)
: _currentDlp == 'CCI'
? CciExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'Kultur'
? KulturExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'Pewarna Langsung'
? PewarnaanLangsungExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'Viral Load'
? ViralLoadExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'IgM IgG Leptospira'
? LeptospiraExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'PCR COVID'
? CovidExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: _currentDlp == 'TBC'
? TbcExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
initialStep: _wizardStep(_currentDlp),
onStepChanged: (step) => _setWizardStep(_currentDlp, step),
)
: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PatientStaffPanel(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
staffValues: _staffValues,
onChanged: () => setState(() {}),
),
const SizedBox(height: 12),
ExpertiseForm(
dlp: _currentDlp,
fields: asList(data['fields']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
onChanged: () => setState(() {}),
),
const SizedBox(height: 12),
ExpertiseActions(
isSupervisor: isSupervisor,
saving: _saving,
onSave: _save,
),
],
),
);
if (_currentDlp.isEmpty) {
return ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
SafeArea(
bottom: false,
child: _ExpertiseHeader(
title: 'Expertise',
subtitle: 'Pilih Template',
saving: _saving,
onRefresh: _reload,
),
),
const SizedBox(height: 22),
summaryCard,
const SizedBox(height: 14),
expertiseContent,
],
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: Column(
children: [
SafeArea(
bottom: false,
child: _ExpertiseHeader(
title: 'Expertise',
subtitle: _currentDlp,
saving: _saving,
onRefresh: _reload,
),
),
const SizedBox(height: 22),
summaryCard,
const SizedBox(height: 14),
Expanded(child: expertiseContent),
],
),
);
},
),
);
}
}
class _ExpertiseHeader extends StatelessWidget {
const _ExpertiseHeader({
required this.title,
required this.subtitle,
required this.saving,
required this.onRefresh,
});
final String title;
final String subtitle;
final bool saving;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
IconButton(
onPressed: saving ? null : onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: const Color(0xFF063B60),
tooltip: 'Refresh',
),
],
);
}
}
class _ExpertiseSummaryCard extends StatelessWidget {
const _ExpertiseSummaryCard({required this.item, required this.dlp});
final Map<String, dynamic> item;
final String dlp;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
BadgeLabel(
text: item['nofoto']?.toString() ?? '-',
color: const Color(0xFF0B7BFF),
),
BadgeLabel(
text: 'RM ${item['noregister'] ?? '-'}',
color: const Color(0xFF667095),
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
if (dlp.isNotEmpty)
BadgeLabel(text: dlp, color: const Color(0xFF0F766E)),
],
),
const SizedBox(height: 12),
Text(
item['reques']?.toString() ?? '-',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
height: 1.35,
),
),
],
),
);
}
}
@@ -1,661 +0,0 @@
part of '../../app/app.dart';
class KulturExpertiseWizard extends StatefulWidget {
const KulturExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<KulturExpertiseWizard> createState() => _KulturExpertiseWizardState();
}
class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 5);
}
@override
void didUpdateWidget(covariant KulturExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 5);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 5);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Sediaan Langsung'),
isActive: _step == 1,
content: _directSmear(),
),
Step(
title: const Text('Biakan Kultur'),
isActive: _step == 2,
content: _cultureGrowth(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 3,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 4,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 5,
content: _finalExpertise(),
),
],
);
}
Widget _directSmear() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'Pewarnaan Gram',
completed: _hasAny([
'id_selepitel',
'id_selradang',
'id_mikroorganisme',
'id_mikroorganismeoptional',
]),
initiallyExpanded: !_hasAny(['id_selepitel', 'id_selradang']),
children: [
_select('id_selepitel', 'Sel Epitel', _options('jsonselepitel')),
_select('id_selradang', 'Sel Radang', _options('jsonselradang')),
_select(
'id_mikroorganisme',
'Mikroorganisme',
_options('jsonmikroorganisme'),
),
_multiSelect(
'id_mikroorganismeoptional',
'Bisa memilih lebih dari satu',
_options('jsonmikroorganismeoptional'),
),
],
),
_nugentScore(),
CollapsibleExpertiseSection(
index: 2,
title: 'Pewarnaan Ziehl Neelsen',
completed: _hasAny([
'id_pewarnaanziehlnielsen',
'id_pewarnaanziehlnielsensewaktu',
]),
children: [
_select(
'id_pewarnaanziehlnielsen',
'Pagi',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'id_pewarnaanziehlnielsensewaktu',
'Sewaktu',
_options('jsonpewarnaanziehlnielsen'),
),
],
),
CollapsibleExpertiseSection(
index: 3,
title: 'Pewarnaan KOH',
completed: _hasAny(['id_pewarnaankoh', 'id_pewarnaankohoptional']),
children: [
_select(
'id_pewarnaankoh',
'Pewarnaan KOH',
_options('jsonpewarnaankoh', _kohFallback),
),
_text('id_pewarnaankohoptional', 'Pewarnaan KOH Manual'),
],
),
CollapsibleExpertiseSection(
index: 4,
title: 'Pewarnaan Neisser',
completed: _hasAny(['id_pewarnaanneisser']),
children: [
_select(
'id_pewarnaanneisser',
'Pewarnaan Neisser',
_options('jsonpewarnaanneisser'),
),
],
),
CollapsibleExpertiseSection(
index: 5,
title: 'Pewarnaan Negatif',
completed: _hasAny(['id_pewarnaannegatif']),
children: [
_select(
'id_pewarnaannegatif',
'Pewarnaan Negatif',
_options('jsonpewarnaannegatif'),
),
],
),
CollapsibleExpertiseSection(
index: 6,
title: 'Pewarnaan Spora',
completed: _hasAny(['id_pewarnaanspora']),
children: [
_select(
'id_pewarnaanspora',
'Pewarnaan Spora',
_options('jsonpewarnaanspora'),
),
],
),
CollapsibleExpertiseSection(
index: 7,
title: 'Pewarnaan Giemsa',
completed: _hasAny([
'id_pewarnaangiesma',
'id_pewarnaangiesmaoptional',
]),
children: [
_select(
'id_pewarnaangiesma',
'Pewarnaan Giemsa',
_options('jsonpewarnaangiemsa', _giemsaFallback),
),
_text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'),
],
),
_saveDirectSmearButton(),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.multiValues[name] ?? const <String>{}).isNotEmpty) {
return true;
}
}
return false;
}
Widget _saveDirectSmearButton() {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('statuspewarnaanlsg'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('SIMPAN PEWARNAAN LANGSUNG'),
),
),
);
}
Widget _nugentScore() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Nugent Score', compact: true),
_select(
'id_jumlahlactobacillus',
'Lactobacillus - Jumlah Per Lapang',
_options('jsonjumlahlactobacillus'),
),
_select('id_lactobacillus', 'Lactobacillus - Skor', _scoreOptions),
_select(
'id_jumlahgardnerella',
'Gardnerella - Jumlah Per Lapang',
_options('jsonjumlahgardnerella'),
),
_select('id_gardnerella', 'Gardnerella - Skor', _scoreOptions),
_select(
'id_jumlahmobiluncus',
'Mobiluncus - Jumlah Per Lapang',
_options('jsonjumlahmobiluncus'),
),
_select('id_mobiluncus', 'Mobiluncus - Skor', _scoreOptions),
],
),
),
);
}
Widget _cultureGrowth() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('B. Biakan Kultur'),
const BadgeLabel(
text: 'Khusus Kultur Darah dan Cairan Steril',
color: Color(0xFFDC2626),
),
const SizedBox(height: 12),
_text('bd_result', 'BD Result'),
_text('bd_result_date', 'BD Result Date'),
_sectionTitle('Pertumbuhan Koloni (Kultur Primer)', compact: true),
_kirbyBauer(),
_select(
'id_biakankultur',
'Biakan Kultur (Aerob / Anaerob)',
_options('jsonbiakankultur'),
),
],
);
}
Widget _kirbyBauer() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Kirby Bauer', compact: true),
for (final antibiotic in _kirbyAntibiotics)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: _select(
antibiotic.zoneField,
antibiotic.label,
_kirbyZoneOptions,
dense: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: _select(
antibiotic.sirField,
'SIR',
_antibioticSirOptions,
dense: true,
),
),
],
),
),
],
),
),
);
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(
String name,
String label,
List<String> options, {
bool dense = false,
}) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: EdgeInsets.only(bottom: dense ? 0 : 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: dense,
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class _KirbyAntibiotic {
const _KirbyAntibiotic(this.label, this.zoneField, this.sirField);
final String label;
final String zoneField;
final String sirField;
}
const List<String> _scoreOptions = ['0', '1', '2', '3', '4'];
const List<String> _antibioticSirOptions = ['S', 'I', 'R'];
const List<String> _kirbyZoneOptions = [
'Tidak dilakukan',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
'29',
'30',
'31',
'32',
'33',
'34',
'35',
'36',
'37',
'38',
'39',
'40',
'41',
'42',
];
const List<_KirbyAntibiotic> _kirbyAntibiotics = [
_KirbyAntibiotic('AMC', 'id_kbamc', 'id_sirkbamc'),
_KirbyAntibiotic('AK', 'id_kbak', 'id_sirkbak'),
_KirbyAntibiotic('FOS', 'id_kbfos', 'id_sirkbfos'),
_KirbyAntibiotic('SCF', 'id_kbscf', 'id_sirkbscf'),
];
const List<String> _kohFallback = [
'Ditemukan morfologi Hifa',
'Ditemukan morfologi Budding Cell',
'Ditemukan morfologi Conidia',
'Ditemukan morfologi Hifa dan Budding Cell',
'Ditemukan morfologi Hifa dan Conidia',
'Ditemukan morfologi Budding Cell dan Conidia',
'Tidak ditemukan morfologi Budding Cell, Hifa dan Conidia',
'Tidak ditemukan morfologi Budding Cell dan Hifa',
'lainnya',
];
const List<String> _giemsaFallback = [
'Ditemukan morfologi Inclusion Bodies dan Reticulate Bodies',
'Tidak ditemukan morfologi Inclusion Bodies dan Reticulate Bodies',
'Ditemukan morfologi kista tropozoid',
'Tidak ditemukan morfologi kista tropozoid',
'lainnya',
];
@@ -1,337 +0,0 @@
part of '../../app/app.dart';
class LeptospiraExpertiseWizard extends StatefulWidget {
const LeptospiraExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<LeptospiraExpertiseWizard> createState() =>
_LeptospiraExpertiseWizardState();
}
class _LeptospiraExpertiseWizardState extends State<LeptospiraExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
@override
void didUpdateWidget(covariant LeptospiraExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 4);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 4);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'IgG Leptospira',
completed: _hasAny(['igg_parameter']),
initiallyExpanded: !_hasAny(['igg_parameter', 'igm_parameter']),
children: [
_select('igg_parameter', 'IgG Leptospira', const [
'Positif',
'Negatif',
]),
],
),
CollapsibleExpertiseSection(
index: 2,
title: 'IgM Leptospira',
completed: _hasAny(['igm_parameter']),
children: [
_select('igm_parameter', 'IgM Leptospira', const [
'Positif',
'Negatif',
]),
],
),
CollapsibleExpertiseSection(
index: 3,
title: 'Interpretasi',
completed: _hasAny(['iggigm_interpretasi']),
children: [
_text(
'iggigm_interpretasi',
'Interpretasi',
minLines: 6,
maxLines: 9,
),
],
),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
}
return false;
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
@@ -1,515 +0,0 @@
part of '../../app/app.dart';
class PewarnaanLangsungExpertiseWizard extends StatefulWidget {
const PewarnaanLangsungExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<PewarnaanLangsungExpertiseWizard> createState() =>
_PewarnaanLangsungExpertiseWizardState();
}
class _PewarnaanLangsungExpertiseWizardState
extends State<PewarnaanLangsungExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
@override
void didUpdateWidget(covariant PewarnaanLangsungExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 4);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 4);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Sediaan Langsung'),
isActive: _step == 1,
content: _directSmear(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _directSmear() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'Pewarnaan Gram',
completed: _hasAny([
'lsg_selepitel',
'lsg_selradang',
'lsg_mikroorganisme',
'lsg_mikroorganismeoptional',
]),
initiallyExpanded: !_hasAny(['lsg_selepitel', 'lsg_selradang']),
children: [
_select('lsg_selepitel', 'Sel Epitel', _options('jsonselepitel')),
_select('lsg_selradang', 'Sel Radang', _options('jsonselradang')),
_select(
'lsg_mikroorganisme',
'Mikroorganisme',
_options('jsonmikroorganisme'),
),
_multiSelect(
'lsg_mikroorganismeoptional',
'Bisa memilih lebih dari satu',
_options('jsonmikroorganismeoptional'),
),
],
),
CollapsibleExpertiseSection(
index: 2,
title: 'Pewarnaan Ziehl Neelsen',
completed: _hasAny([
'lsg_pewarnaanziehlnielsen',
'lsg_pewarnaanziehlnielsensewaktu',
]),
children: [
_select(
'lsg_pewarnaanziehlnielsen',
'Pagi',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'lsg_pewarnaanziehlnielsensewaktu',
'Sewaktu',
_options('jsonpewarnaanziehlnielsen'),
),
],
),
CollapsibleExpertiseSection(
index: 3,
title: 'Pewarnaan KOH',
completed: _hasAny(['lsg_pewarnaankoh', 'lsg_pewarnaankohoptional']),
children: [
_select(
'lsg_pewarnaankoh',
'Pewarnaan KOH',
_options('jsonpewarnaankoh', _kohFallback),
),
_text('lsg_pewarnaankohoptional', 'Pewarnaan KOH Manual'),
],
),
CollapsibleExpertiseSection(
index: 4,
title: 'Pewarnaan Neisser',
completed: _hasAny(['lsg_pewarnaanneisser']),
children: [
_select(
'lsg_pewarnaanneisser',
'Pewarnaan Neisser',
_options('jsonpewarnaanneisser'),
),
],
),
CollapsibleExpertiseSection(
index: 5,
title: 'Pewarnaan Negatif',
completed: _hasAny(['lsg_pewarnaannegatif']),
children: [
_select(
'lsg_pewarnaannegatif',
'Pewarnaan Negatif',
_options('jsonpewarnaannegatif'),
),
],
),
CollapsibleExpertiseSection(
index: 6,
title: 'Pewarnaan Spora',
completed: _hasAny(['lsg_pewarnaanspora']),
children: [
_select(
'lsg_pewarnaanspora',
'Pewarnaan Spora',
_options('jsonpewarnaanspora'),
),
],
),
CollapsibleExpertiseSection(
index: 7,
title: 'Pewarnaan Giemsa',
completed: _hasAny([
'lsg_pewarnaangiesma',
'lsg_pewarnaangiesmaoptional',
]),
children: [
_select(
'lsg_pewarnaangiesma',
'Pewarnaan Giemsa',
_options('jsonpewarnaangiemsa', _giemsaFallback),
),
_text('lsg_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'),
],
),
CollapsibleExpertiseSection(
index: 8,
title: 'Pewarnaan Lain-Lain',
completed: _hasAny(['lsg_pewarnaanlain']),
children: [
_text(
'lsg_pewarnaanlain',
'Pewarnaan Lain-Lain',
minLines: 5,
maxLines: 8,
),
],
),
_nugentScore(),
_saveDirectSmearButton(),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.multiValues[name] ?? const <String>{}).isNotEmpty) {
return true;
}
}
return false;
}
Widget _saveDirectSmearButton() {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('statuspewarnaanlsg'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('SIMPAN PEWARNAAN LANGSUNG'),
),
),
);
}
Widget _nugentScore() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Nugent Score', compact: true),
_select(
'lsg_jumlahlactobacillus',
'Lactobacillus - Jumlah Per Lapang',
_options('jsonjumlahlactobacillus'),
),
_select('lsg_lactobacillus', 'Lactobacillus - Skor', _scoreOptions),
_select(
'lsg_jumlahgardnerella',
'Gardnerella - Jumlah Per Lapang',
_options('jsonjumlahgardnerella'),
),
_select('lsg_gardnerella', 'Gardnerella - Skor', _scoreOptions),
_select(
'lsg_jumlahmobiluncus',
'Mobiluncus - Jumlah Per Lapang',
_options('jsonjumlahmobiluncus'),
),
_select('lsg_mobiluncus', 'Mobiluncus - Skor', _scoreOptions),
],
),
),
);
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
@@ -1,664 +0,0 @@
part of '../../app/app.dart';
class TbcExpertiseWizard extends StatefulWidget {
const TbcExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<TbcExpertiseWizard> createState() => _TbcExpertiseWizardState();
}
class _TbcExpertiseWizardState extends State<TbcExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 6);
}
@override
void didUpdateWidget(covariant TbcExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 6);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 6);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Riwayat & Identitas Nasional'),
isActive: _step == 1,
content: _historyAndIdentity(),
),
Step(
title: const Text('Jenis Pemeriksaan'),
isActive: _step == 2,
content: _testType(),
),
Step(
title: const Text('Hasil Pemeriksaan'),
isActive: _step == 3,
content: _examinationResultByType(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 4,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 5,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 6,
content: _finalExpertise(),
),
],
);
}
Widget _historyAndIdentity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'Jenis Terduga / Pasien TBC',
completed: _hasAny([
'jenis_pasien_tbc',
'id_noidsediaan',
'id_tanggalpengambilancontohuji',
'id_tanggalpengirimancontohuji',
'id_lamariwayatpengobatantb',
]),
initiallyExpanded: !_hasAny(['jenis_pasien_tbc', 'id_noidsediaan']),
children: [
_multiSelect(
'jenis_pasien_tbc',
'Jenis Terduga / Pasien TBC',
const ['TBC SO', 'TBC RO', 'Anak', 'HIV', 'DM'],
),
_text('id_noidsediaan', 'No. Identitas Sediaan'),
_text(
'id_tanggalpengambilancontohuji',
'Tanggal Pengambilan Contoh Uji',
),
_text(
'id_tanggalpengirimancontohuji',
'Tanggal Pengiriman Contoh Uji',
),
_select(
'id_lamariwayatpengobatantb',
'Lama Riwayat Pengobatan Terduga/Pasien TBC',
const [
'Riwayat Pengobatan >= 5 tahun',
'Riwayat Pengobatan < 5 tahun',
],
),
],
),
CollapsibleExpertiseSection(
index: 2,
title: 'Identitas Nasional',
completed: _hasAny([
'id_lokasianatomi',
'id_alasanpemeriksaan',
'id_noregfasyankes',
'id_noregkota',
]),
children: [
_select('id_lokasianatomi', 'Lokasi Anatomi', const [
'Paru',
'Ekstraparu',
]),
_text('id_ekstraparu', 'Ekstraparu'),
_select('id_alasanpemeriksaan', 'Alasan Pemeriksaan', const [
'Diagnosis TBC',
'Diagnosis Baseline TBC',
'Akhir Pengobatan',
]),
_text(
'id_bulankemajuantbfollow',
'Pemantauan kemajuan pengobatan (Bulan ke)',
),
_text('id_bulanpemeriksaanulang', 'Pemeriksaan ulang (Bulan ke)'),
_text(
'id_bulanpemeriksaansetelahselesai',
'Pemeriksaan setelah selesai pengobatan (Bulan ke)',
),
_text('id_noregfasyankes', 'No.Reg.TBC/TBC RO Fasyankes'),
_text('id_noregkota', 'No.Reg.TBC/TBC RO Kab/Kota'),
],
),
CollapsibleExpertiseSection(
index: 3,
title: 'Contoh Uji dan Visual Dahak',
completed: _hasAny([
'id_contohuji',
'id_contohujilainnya',
'visual_dahak_sewaktu',
'visual_dahak_pagi',
]),
children: [
_select('id_contohuji', 'Contoh Uji', const ['Dahak', 'Lainnya']),
_text('id_contohujilainnya', 'Contoh Uji Lainnya'),
_multiSelect('visual_dahak_sewaktu', 'Visual dahak Sewaktu', const [
'Nanah Lendir',
'Bercak darah',
'Air liur',
]),
_multiSelect('visual_dahak_pagi', 'Visual dahak Pagi', const [
'Nanah Lendir',
'Bercak darah',
'Air liur',
]),
],
),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.multiValues[name] ?? const <String>{}).isNotEmpty) {
return true;
}
}
return false;
}
Widget _testType() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'Jenis Pemeriksaan',
completed: _hasAny(['id_jenispemeriksaantb']),
initiallyExpanded: !_hasAny(['id_jenispemeriksaantb']),
children: [
_select(
'id_jenispemeriksaantb',
'Jenis Pemeriksaan',
_tbcTestTypes,
),
],
),
],
);
}
Widget _examinationResultByType() {
final type = widget.selectValues['id_jenispemeriksaantb'] ?? '';
if (type == 'TCM XDR (Xpert)') {
return _xdrResult();
}
if (type == 'Biakan') {
return _cultureResult();
}
if (_tcmRifTypes.contains(type)) {
return _tcmMtbRifResult();
}
return _microscopicResult();
}
Widget _microscopicResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Pemeriksaan Mikroskopis'),
_text('hasilpemeriksaantmikroskopis_tglsewaktu', 'Tanggal Sewaktu'),
_select(
'hasilpemeriksaantmikroskopis_hasilsewaktu',
'Hasil Sewaktu',
_tbMicroscopeResults,
),
_text('hasilpemeriksaantmikroskopis_tglpagi', 'Tanggal Pagi'),
_select(
'hasilpemeriksaantmikroskopis_hasilpagi',
'Hasil Pagi',
_tbMicroscopeResults,
),
],
);
}
Widget _tcmMtbRifResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Pemeriksaan TCM MTB Rif'),
_text('hasilpemeriksaanttcmmtbrifxpert_tglsewaktu', 'Tanggal Sewaktu'),
_text('hasilpemeriksaanttcmmtbrifxpert_kodeunik', 'Kode Unik Sewaktu'),
_select(
'hasilpemeriksaanttcmmtbrifxpert_hasilsewaktu',
'Hasil Sewaktu',
_tcmMtbRifResults,
),
_text('hasilpemeriksaanttcmmtbrifxpert_tglpagi', 'Tanggal Pagi'),
_text('hasilpemeriksaanttcmmtbrifxpert_kodeunikpagi', 'Kode Unik Pagi'),
_select(
'hasilpemeriksaanttcmmtbrifxpert_hasilpagi',
'Hasil Pagi',
_tcmMtbRifResults,
),
],
);
}
Widget _cultureResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Biakan'),
_text('hasilpemeriksaantbbiakan_tgl', 'Tanggal Mikroskopis'),
_select(
'hasilpemeriksaantbbiakan_pagi',
'Mikroskopis Pagi',
_tbMicroscopeResults,
),
_select(
'hasilpemeriksaantbbiakan_sewaktu',
'Mikroskopis Sewaktu',
_tbMicroscopeResults,
),
_text('hasilpemeriksaantbbiakan_tglbiakan', 'Tanggal Biakan'),
_select(
'hasilpemeriksaantbbiakan_hasilbiakanmgit',
'Hasil Biakan (MGIT)',
const ['Pos', 'Neg', 'NTM', 'KTM', 'TDL'],
),
_select(
'hasilpemeriksaantbbiakan_hasilbiakanlj',
'Hasil Biakan (LJ)',
const ['3+', '2+', '1+', '1-9', 'Neg', 'NTM', 'KTM', 'TDL'],
),
],
);
}
Widget _xdrResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('TCM XDR (Xpert) - Pagi'),
_text('hasilpemeriksaanttcmmtbxdrxpert_tglpagi', 'Tanggal Pagi'),
_text('hasilpemeriksaanttcmmtbxdrxpert_kodeunikpagi', 'Kode Unik Pagi'),
_xdrPanel('pagi'),
const SizedBox(height: 12),
_sectionTitle('TCM XDR (Xpert) - Sewaktu'),
_text('hasilpemeriksaanttcmmtbxdrxpert_tglsewaktu', 'Tanggal Sewaktu'),
_text('hasilpemeriksaanttcmmtbxdrxpert_kodeunik', 'Kode Unik Sewaktu'),
_xdrPanel('sewaktu'),
],
);
}
Widget _xdrPanel(String prefix) {
final normalized = prefix == 'pagi' ? 'pagi' : 'sewaktu';
final inhLowField = prefix == 'pagi'
? 'hasilpemeriksaanttcmmtbxdrxpert_paginhlow'
: 'hasilpemeriksaanttcmmtbxdrxpert_sewaktunhlow';
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
_select(
'hasilpemeriksaanttcmmtbxdrxpert_$normalized',
'TCM XDR',
_xdrValidityResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}mtb',
'MTB',
const ['Detected', 'Not Detected'],
),
_select(inhLowField, 'H Low', _xdrSirResults),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}h',
'H',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fqlow',
'FQ Low',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fq',
'FQ',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}amk',
'Amk',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}km',
'Km',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}cm',
'Cm',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}eto',
'Eto',
_xdrSirResults,
),
],
),
),
);
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
const List<String> _tbcTestTypes = [
'Mikroskopis',
'TCM MTB Rif (Xpert)',
'TCM XDR (Xpert)',
'TCM MTB Rif (Truenat)',
'TCM MTB Rif INH (BDMAX)',
'PCR Open System',
'LPA lini 1',
'LPA lini 2',
'Biakan',
'Paket standar uji kepekaan',
];
const Set<String> _tcmRifTypes = {
'TCM MTB Rif (Xpert)',
'TCM MTB Rif (Truenat)',
'TCM MTB Rif INH (BDMAX)',
'LPA lini 1',
'LPA lini 2',
};
const List<String> _tbMicroscopeResults = ['+++', '++', '+', '1-9', 'Neg'];
const List<String> _tcmMtbRifResults = [
'Neg',
'Rif Sen',
'Rif Res',
'Rif Indet',
'Invalid',
'Error',
'No result',
];
const List<String> _xdrValidityResults = [
'Valid',
'Invalid',
'No Result',
'Error',
];
const List<String> _xdrSirResults = [
'S',
'I',
'R',
'Invalid',
'Error',
'No Result',
];
@@ -1,55 +0,0 @@
part of '../../app/app.dart';
class TemplatePicker extends StatelessWidget {
const TemplatePicker({
super.key,
required this.templates,
required this.saving,
required this.onUse,
});
final List<dynamic> templates;
final bool saving;
final ValueChanged<String> onUse;
@override
Widget build(BuildContext context) {
final knownTemplates = templates
.map(asMap)
.where((template) => _knownDlp.contains(template['judul']?.toString()))
.toList();
if (knownTemplates.isEmpty) {
return const EmptyPanel(text: 'Template expertise belum tersedia.');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(title: 'Pilih Template'),
...knownTemplates.map(
(template) => Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(
Icons.article_outlined,
color: Color(0xFF0F766E),
),
title: Text(template['judul']?.toString() ?? '-'),
subtitle: Text('Kategori ${template['kategori'] ?? '-'}'),
trailing: FilledButton(
onPressed: saving
? null
: () => onUse(template['judul'].toString()),
child: const Text('Pakai'),
),
),
),
),
],
);
}
}
@@ -1,404 +0,0 @@
part of '../../app/app.dart';
class ViralLoadExpertiseWizard extends StatefulWidget {
const ViralLoadExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<ViralLoadExpertiseWizard> createState() =>
_ViralLoadExpertiseWizardState();
}
class _ViralLoadExpertiseWizardState extends State<ViralLoadExpertiseWizard> {
late int _step;
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
@override
void didUpdateWidget(covariant ViralLoadExpertiseWizard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextStep = widget.initialStep.clamp(0, 4);
if (nextStep != _step) {
_step = nextStep;
}
}
void _setStep(int step) {
final nextStep = step.clamp(0, 4);
setState(() => _step = nextStep);
widget.onStepChanged(nextStep);
}
@override
Widget build(BuildContext context) {
return ExpertiseWizardShell(
currentStep: _step,
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CollapsibleExpertiseSection(
index: 1,
title: 'Parameter Viral Load',
completed: _hasAny([
'vl_parameter01',
'vl_hasil01',
'vl_hasil01teks',
'vl_satuan01',
'vl_rujukan01',
]),
initiallyExpanded: !_hasAny(['vl_parameter01', 'vl_hasil01']),
children: [
_select(
'vl_parameter01',
'Parameter',
_options('jsonvlparameter', const [
'HIV RNA',
'HCV RNA',
'HBV DNA',
]),
),
_select('vl_hasil01', 'Hasil', _viralLoadResultOptions),
_text('vl_hasil01teks', 'Hasil Manual'),
_select('vl_satuan01', 'Satuan', const ['copies/mL', 'IU/mL']),
_select('vl_rujukan01', 'Nilai Rujukan', const [
'40-10.000.000',
'10-100.000.000',
]),
],
),
CollapsibleExpertiseSection(
index: 2,
title: 'Parameter 02',
completed: _hasAny([
'vl_parameter02',
'vl_hasil02',
'vl_hasil02teks',
'vl_satuan02',
'vl_rujukan02',
]),
children: [
_select('vl_parameter02', 'Parameter 02', const ['LOG']),
_select('vl_hasil02', 'Hasil 02', _viralLoadLogResultOptions),
_text('vl_hasil02teks', 'Hasil 02 Manual'),
_select('vl_satuan02', 'Satuan 02', const ['LOG']),
_select('vl_rujukan02', 'Nilai Rujukan 02', const ['1,6-7', ' ']),
],
),
CollapsibleExpertiseSection(
index: 3,
title: 'Catatan Viral Load',
completed: _hasAny(['viralload']),
children: [
_text('viralload', 'Catatan Viral Load', minLines: 6, maxLines: 9),
],
),
],
);
}
bool _hasAny(List<String> names) {
for (final name in names) {
if ((widget.selectValues[name] ?? '').trim().isNotEmpty) {
return true;
}
if ((widget.textControllers[name]?.text ?? '').trim().isNotEmpty) {
return true;
}
}
return false;
}
Widget _antibioticSensitivity() {
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _toolData() {
return ToolDataPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpertiseHtmlEditor(
controller: widget.textControllers['keterangan'] ??=
TextEditingController(),
label: 'Expertise',
),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
// ignore: unused_element
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
const List<String> _viralLoadResultOptions = [
'NOT DETECTED',
'<40',
'<1.60',
'40',
'41',
'44',
'45',
'145',
'<10',
'20',
'322',
'511',
'144',
'211',
'lainnya',
];
const List<String> _viralLoadLogResultOptions = [
'NOT DETECTED',
'<1,60',
'<1,85',
'1,60',
'1,61',
'1,64',
'1,65',
'2,16',
'1,00',
'1,30',
'2,51',
'2,71',
'2,34',
'lainnya',
];
-304
View File
@@ -1,304 +0,0 @@
part of '../../app/app.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _baseUrl = TextEditingController(text: kDefaultBaseUrl);
final _username = TextEditingController();
final _password = TextEditingController();
bool _obscure = true;
bool _loading = false;
bool _pinging = false;
String? _pingStatus;
@override
void initState() {
super.initState();
SessionStore.session().then((value) {
if (!mounted) {
return;
}
_baseUrl.text = value.baseUrl;
_username.text = value.username ?? '';
_password.text = value.password ?? '';
});
}
@override
void dispose() {
_baseUrl.dispose();
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _loading = true);
try {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
final api = ApiClient(baseUrl: serverUrl);
final data = await api.post('api/mobile/login', {
'username': _username.text.trim(),
'password': _password.text,
});
final token = data['token']?.toString();
if (token == null || token.isEmpty) {
throw ApiException('Token login tidak diterima dari server.');
}
await SessionStore.saveLogin(
baseUrl: serverUrl,
token: token,
username: _username.text.trim(),
password: _password.text,
);
if (!mounted) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl),
),
);
} on ApiException catch (error) {
_showMessage(error.message);
} catch (error, stackTrace) {
debugPrint('Login MyLIS gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
_showMessage('Tidak bisa terhubung ke server MyLIS. Penyebab: $error');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _ping() async {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
if (serverUrl.isEmpty) {
return;
}
setState(() {
_pinging = true;
_pingStatus = null;
});
try {
await SessionStore.saveBaseUrl(serverUrl);
final data = await ApiClient(baseUrl: serverUrl).get('api/mobile/ping');
final appName = data['application']?.toString();
setState(() {
_baseUrl.text = serverUrl;
_pingStatus = appName == null || appName.isEmpty
? 'Server bisa diakses.'
: 'Server bisa diakses: $appName';
});
} on ApiException catch (error) {
setState(() => _pingStatus = error.message);
} catch (error, stackTrace) {
debugPrint('Ping MyLIS gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
setState(() => _pingStatus = 'Koneksi server gagal. Penyebab: $error');
} finally {
if (mounted) setState(() => _pinging = false);
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Image.asset(
kAppLogoAsset,
height: 78,
fit: BoxFit.contain,
),
),
const SizedBox(width: 18),
Container(
width: 1,
height: 58,
color: const Color(0xFFE2E8F0),
),
const SizedBox(width: 18),
Flexible(
child: Image.asset(
kHospitalLogoAsset,
height: 78,
fit: BoxFit.contain,
),
),
],
),
const SizedBox(height: 18),
Text(
kHospitalName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF0F766E),
),
),
const SizedBox(height: 8),
Text(
kAppLongName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Login petugas',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.black54),
),
const SizedBox(height: 32),
TextFormField(
controller: _baseUrl,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link),
labelText: 'IP Server',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Ping server',
onPressed: _pinging ? null : _ping,
icon: _pinging
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.network_ping),
),
),
validator: (value) {
final url = normalizeBaseUrl(value ?? '');
final uri = Uri.tryParse(url);
return uri == null ||
!uri.hasScheme ||
!uri.hasAuthority
? 'URL Laravel tidak valid'
: null;
},
),
if (_pingStatus != null) ...[
const SizedBox(height: 8),
Text(
_pingStatus!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _pingStatus!.contains('bisa diakses')
? const Color(0xFF15803D)
: const Color(0xFFDC2626),
),
),
],
const SizedBox(height: 14),
TextFormField(
controller: _username,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.person_outline),
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Username wajib diisi'
: null,
),
const SizedBox(height: 14),
TextFormField(
controller: _password,
obscureText: _obscure,
onFieldSubmitted: (_) => _login(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_outline),
labelText: 'Password',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: _obscure
? 'Tampilkan password'
: 'Sembunyikan password',
onPressed: () => setState(() => _obscure = !_obscure),
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => value == null || value.isEmpty
? 'Password wajib diisi'
: null,
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: _loading ? null : _login,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('Login'),
),
const SizedBox(height: 18),
Text(
'Duidev Software House | CV Swandhana',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.black45),
),
const SizedBox(height: 18),
Text(
'Copyright @ 2026',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.black54,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
),
);
}
}
-290
View File
@@ -1,290 +0,0 @@
part of '../app/app.dart';
class ApiClient {
ApiClient({required this.baseUrl, this.token});
final String baseUrl;
String? token;
Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
final base = Uri.parse(normalizedBase);
return base.replace(
path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path',
queryParameters: query,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
return _sendWithRenew(
path,
(client, uri) => client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15)),
);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
return _sendWithRenew(
path,
(client, uri) => client
.get(uri, headers: _headers)
.timeout(const Duration(seconds: 15)),
query,
);
}
Future<Map<String, dynamic>> _sendWithRenew(
String path,
Future<http.Response> Function(http.Client client, Uri uri) send, [
Map<String, String>? query,
]) async {
final uri = _uri(path, query);
try {
return await _sendOnce(uri, send);
} on ApiException catch (error) {
if (!_canRenew(path, error)) {
rethrow;
}
final renewed = await _renewToken(uri);
if (!renewed) {
rethrow;
}
debugPrint('MyLIS session diperbarui, mengulang request: $uri');
return _sendOnce(uri, send);
}
}
Future<Map<String, dynamic>> _sendOnce(
Uri uri,
Future<http.Response> Function(http.Client client, Uri uri) send,
) async {
final client = _clientFor(uri);
try {
final response = await send(client, uri);
return _decode(response, uri);
} catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace);
} finally {
client.close();
}
}
bool _canRenew(String path, ApiException error) {
return token != null &&
error.statusCode == 401 &&
path != 'api/mobile/login' &&
!path.endsWith('/login');
}
Future<bool> _renewToken(Uri failedUri) async {
final credentials = await SessionStore.credentials();
if (credentials == null) {
debugPrint(
'MyLIS session expired untuk $failedUri; kredensial tersimpan tidak ada.',
);
return false;
}
final loginUri = _uri('api/mobile/login');
final client = _clientFor(loginUri);
try {
debugPrint(
'MyLIS session expired untuk $failedUri; mencoba renew token.',
);
final response = await client
.post(
loginUri,
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': credentials.username,
'password': credentials.password,
}),
)
.timeout(const Duration(seconds: 15));
final data = _decode(response, loginUri);
final token = data['token']?.toString() ?? '';
if (token.isEmpty) {
debugPrint(
'MyLIS renew token gagal: respons login tidak berisi token.',
);
return false;
}
this.token = token;
await SessionStore.saveLogin(
baseUrl: baseUrl,
token: token,
username: credentials.username,
password: credentials.password,
);
return true;
} on ApiException catch (error) {
debugPrint('MyLIS renew token gagal: ${error.message}');
return false;
} catch (error, stackTrace) {
debugPrint('MyLIS renew token gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
return false;
} finally {
client.close();
}
}
http.Client _clientFor(Uri uri) {
final client = HttpClient()
..badCertificateCallback = (certificate, host, port) {
debugPrint(
'MyLIS menerima sertifikat internal untuk $host:$port '
'issuer=${certificate.issuer}',
);
return true;
};
return IOClient(client);
}
Map<String, dynamic> _decode(http.Response response, Uri uri) {
final bodyPreview = response.body.length > 240
? '${response.body.substring(0, 240)}...'
: response.body;
if (response.statusCode < 200 || response.statusCode >= 300) {
Object? decoded;
if (_looksLikeJson(response)) {
try {
decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
} catch (_) {
decoded = null;
}
}
final message = decoded is Map ? decoded['message'] : null;
debugPrint(
'MyLIS API gagal: ${response.request?.method ?? '-'} $uri '
'HTTP ${response.statusCode}; body=$bodyPreview',
);
throw ApiException(
message?.toString() ??
'Server mengembalikan HTTP ${response.statusCode}. '
'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. '
'Detail awal respons: $bodyPreview',
statusCode: response.statusCode,
);
}
final Object decoded;
try {
decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
} catch (error, stackTrace) {
debugPrint(
'MyLIS API decode gagal: ${response.request?.method ?? '-'} $uri '
'HTTP ${response.statusCode}; error=$error',
);
debugPrintStack(stackTrace: stackTrace);
throw ApiException(
'Respons server tidak bisa dibaca. '
'URL: $uri. Status HTTP: ${response.statusCode}.',
statusCode: response.statusCode,
);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
final cleanDecoded = _decodeHtmlEntitiesIn(decoded);
return cleanDecoded is Map<String, dynamic>
? cleanDecoded
: {'data': cleanDecoded};
}
return {'data': _decodeHtmlEntitiesIn(decoded)};
}
bool _looksLikeJson(http.Response response) {
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final body = response.body.trimLeft();
return contentType.contains('json') ||
body.startsWith('{') ||
body.startsWith('[');
}
ApiException _connectionException(
Uri uri,
Object error,
StackTrace stackTrace,
) {
if (error is ApiException) {
return error;
}
final reason = _connectionReason(error);
debugPrint('MyLIS koneksi gagal: $uri; $reason; error=$error');
debugPrintStack(stackTrace: stackTrace);
return ApiException('Koneksi server gagal. URL: $uri. Penyebab: $reason');
}
String _connectionReason(Object error) {
if (error is TimeoutException) {
return 'timeout lebih dari 15 detik. Periksa jaringan, DNS lokal, atau server Laravel belum aktif.';
}
if (error is http.ClientException) {
final message = error.message.toLowerCase();
if (message.contains('certificate') ||
message.contains('handshake') ||
message.contains('tls')) {
return 'sertifikat HTTPS lokal belum dipercaya oleh device/simulator.';
}
if (message.contains('failed host lookup') ||
message.contains('nodename') ||
message.contains('name or service')) {
return 'host tidak ditemukan. Periksa URL atau DNS/hosts lokal.';
}
if (message.contains('connection refused')) {
return 'server menolak koneksi. Periksa apakah Laravel/web server aktif.';
}
if (message.contains('connection closed')) {
return 'koneksi ditutup oleh server.';
}
return 'client HTTP gagal: ${error.message}';
}
if (error is FormatException) {
return 'format URL atau respons tidak valid: ${error.message}';
}
return error.toString();
}
Object? _decodeHtmlEntitiesIn(Object? value) {
if (value is String) {
return decodeHtmlEntities(value);
}
if (value is List) {
return value.map(_decodeHtmlEntitiesIn).toList();
}
if (value is Map) {
return value.map(
(key, item) => MapEntry(key.toString(), _decodeHtmlEntitiesIn(item)),
);
}
return value;
}
}
class ApiException implements Exception {
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
}
-101
View File
@@ -1,101 +0,0 @@
part of '../app/app.dart';
class SessionStore {
static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url';
static const _usernameKey = 'mylis_username';
static const _passwordKey = 'mylis_password';
static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<String> baseUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl;
}
static Future<SessionData> session() async {
final prefs = await SharedPreferences.getInstance();
return SessionData(
token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
username: prefs.getString(_usernameKey),
password: prefs.getString(_passwordKey),
);
}
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<void> saveLogin({
required String baseUrl,
required String token,
required String username,
required String password,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
await prefs.setString(_tokenKey, token);
await prefs.setString(_usernameKey, username);
await prefs.setString(_passwordKey, password);
}
static Future<StoredCredentials?> credentials() async {
final prefs = await SharedPreferences.getInstance();
final username = prefs.getString(_usernameKey);
final password = prefs.getString(_passwordKey);
if (username == null ||
username.trim().isEmpty ||
password == null ||
password.isEmpty) {
return null;
}
return StoredCredentials(username: username, password: password);
}
static Future<void> saveBaseUrl(String baseUrl) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
}
}
class SessionData {
const SessionData({
this.token,
this.baseUrl = kDefaultBaseUrl,
this.username,
this.password,
});
final String? token;
final String baseUrl;
final String? username;
final String? password;
}
class StoredCredentials {
const StoredCredentials({required this.username, required this.password});
final String username;
final String password;
}
String normalizeBaseUrl(String value) {
var url = value.trim();
if (url.isEmpty) {
return kDefaultBaseUrl;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
return url.replaceAll(RegExp(r'/+$'), '');
}
-214
View File
@@ -1,214 +0,0 @@
part of '../app/app.dart';
class DetailRow extends StatelessWidget {
const DetailRow({super.key, required this.label, required this.value});
final String label;
final Object? value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(color: Colors.black54, fontSize: 12),
),
const SizedBox(height: 2),
Text(
value?.toString().isNotEmpty == true ? value.toString() : '-',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
);
}
}
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.status});
final String status;
@override
Widget build(BuildContext context) {
final normalized = status.trim();
final lower = normalized.toLowerCase();
if (normalized.isEmpty || lower == 'new') {
return const BadgeLabel(text: 'NEW', color: Color(0xFFDC2626));
}
if (lower.contains('dibatalkan')) {
return const Text(
'Batal',
style: TextStyle(color: Color(0xFF6B7280), fontWeight: FontWeight.w800),
);
}
if (lower.contains('pemeriksaan sampel')) {
return const Text(
'Pemeriksaan Sampel',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
);
}
if (lower.contains('proses analisis sampel')) {
return const Text(
'Diperiksa',
style: TextStyle(color: Color(0xFF111827), fontWeight: FontWeight.w900),
);
}
if (lower.contains('draft')) {
return const Text(
'Draft',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
);
}
if (lower.contains('expertise')) {
return const Text(
'Expertise',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w900),
);
}
if (lower.contains('decliend')) {
return const Text(
'Decliend',
style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.w900),
);
}
if (lower.contains('selesai')) {
return const BadgeLabel(text: 'Selesai', color: Color(0xFF15803D));
}
if (lower.contains('arsip')) {
return const BadgeLabel(text: 'Arsip', color: Color(0xFF2563EB));
}
if (lower.contains('data vitek di terima')) {
return const BadgeLabel(
text: 'Data Vitek di Terima',
color: Color(0xFFF59E0B),
);
}
return BadgeLabel(text: normalized, color: const Color(0xFF0891B2));
}
}
class BadgeLabel extends StatelessWidget {
const BadgeLabel({super.key, required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
}
class EmptyPanel extends StatelessWidget {
const EmptyPanel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(text, style: const TextStyle(color: Colors.black54)),
),
);
}
}
class ErrorView extends StatelessWidget {
const ErrorView({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: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 42, color: Color(0xFFDC2626)),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
],
),
),
);
}
}
class LoadingScreen extends StatelessWidget {
const LoadingScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
Map<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return <String, dynamic>{};
}
List<dynamic> asList(Object? value) => value is List ? value : <dynamic>[];
String _message(Object? error) => error is ApiException
? error.message
: 'Terjadi kesalahan saat memuat data.';
String decodeHtmlEntities(String value) {
var result = value;
for (var i = 0; i < 3; i += 1) {
final previous = result;
result = result
.replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) {
final code = int.tryParse(match.group(1) ?? '');
return code == null ? match.group(0)! : String.fromCharCode(code);
});
result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) {
final code = int.tryParse(match.group(1) ?? '', radix: 16);
return code == null ? match.group(0)! : String.fromCharCode(code);
});
if (result == previous) {
break;
}
}
return result;
}
-1
View File
@@ -1 +0,0 @@
flutter/ephemeral

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