Update
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 67 KiB |
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
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>
|
||||
@@ -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"
|
||||
@@ -1,6 +0,0 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 '-';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
];
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'/+$'), '');
|
||||
}
|
||||
@@ -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('>', '>')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
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 +0,0 @@
|
||||
flutter/ephemeral
|
||||