This commit is contained in:
Dwi Swandhana
2026-07-22 09:07:43 +07:00
parent 92dc35396b
commit 9e4774bd5e
22 changed files with 2272 additions and 850 deletions
@@ -386,6 +386,7 @@ class MobileApiController extends Controller
'preliminary' => 'Preliminary Results',
'Permohonan Verifikasi' => 'Expertise Saved (Un Verified)',
'Permohonan Verifikasi Preliminary' => 'Otor Maldi (Un Verified)',
'statuspewarnaanlsg' => $this->resolvePeriksaHierarchyStatus($row->status, 'Pewarnaan Langsung'),
default => 'Pemeriksaan awal',
};
@@ -424,6 +425,7 @@ class MobileApiController extends Controller
'preliminary' => 'Preliminary Results',
'Permohonan Verifikasi' => 'Kirim ke SPV',
'Permohonan Verifikasi Preliminary' => 'Kirim Preliminary ke SPV',
'statuspewarnaanlsg' => 'Pewarnaan Langsung',
default => 'Draft',
},
'verifikasi' => in_array($action, ['verifikasi', 'preliminary'], true) ? 'Accepted' : '',
@@ -435,6 +437,7 @@ class MobileApiController extends Controller
'preliminary' => 'Preliminary Results Saved',
'Permohonan Verifikasi' => 'Permohonan Verifikasi Terkirim',
'Permohonan Verifikasi Preliminary' => 'Permohonan Verifikasi Preliminary Terkirim',
'statuspewarnaanlsg' => 'Pewarnaan Langsung tersimpan',
default => 'Draft Expertise Saved',
},
'item' => $this->examinationPayload($row->fresh()),
@@ -958,6 +961,123 @@ class MobileApiController extends Controller
};
}
protected function resolvePeriksaHierarchyStatus($currentStatus, $proposedStatus): string
{
$currentStatus = trim((string) $currentStatus);
$proposedStatus = trim((string) $proposedStatus);
if ($proposedStatus === '') {
return $currentStatus;
}
$currentRank = $this->periksaHierarchyStatusRank($currentStatus);
$proposedRank = $this->periksaHierarchyStatusRank($proposedStatus);
if ($currentStatus === '') {
return $proposedStatus;
}
if ($proposedRank === null) {
return $currentRank === null ? $proposedStatus : $currentStatus;
}
if ($currentRank === null || $proposedRank >= $currentRank) {
return $proposedStatus;
}
return $currentStatus;
}
protected function periksaHierarchyStatusRank($status): ?int
{
$normalized = $this->normalizePeriksaStatus($status);
if ($normalized === '') {
return null;
}
if ($normalized === 'penerimaan sampel') {
return 1;
}
if (in_array($normalized, ['pemeriksaan awal', 'pemeriksaan sampel', 'proses analisis sampel'], true)) {
return 2;
}
if (str_starts_with($normalized, 'data bd di terima') || str_starts_with($normalized, 'data bd diterima')) {
return 3;
}
if ($normalized === 'pewarnaan langsung') {
return 4;
}
if ($this->isProsesingKoloniMediaStatus($normalized)) {
return 5;
}
if (str_starts_with($normalized, 'data malditof diterima') || str_starts_with($normalized, 'data malditof di terima')) {
return 6;
}
if (str_starts_with($normalized, 'otor maldi (un verified)')) {
return 7;
}
if (str_starts_with($normalized, 'preliminary results')) {
return 8;
}
if (str_starts_with($normalized, 'data vitek di terima') || str_starts_with($normalized, 'data vitek diterima')) {
return 9;
}
if (str_starts_with($normalized, 'id/ast pending result') || $normalized === 'sedang id+ast') {
return 10;
}
if (str_starts_with($normalized, 'expertise saved (un verified)')
|| in_array($normalized, ['expertise', 'selesai', 'final result'], true)) {
return 11;
}
return null;
}
protected function normalizePeriksaStatus($status): string
{
$status = strtolower(trim((string) $status));
$status = str_replace(['_', '-'], ' ', $status);
$status = preg_replace('/\s+/', ' ', $status);
return trim($status);
}
protected function isProsesingKoloniMediaStatus(string $normalizedStatus): bool
{
$exactStatuses = [
'tidak ada pertumbuhan',
'tidak lanjut identifikasi',
'tidak tindak lanjut identifikasi',
'inkubasi lanjutan',
'sub kultur',
'menunggu kultur yg lain',
'menunggu kultur yang lain',
'proses identifikasi dan uji kepekaan',
'sedang malditof',
'pertumbuhan primer',
];
if (in_array($normalizedStatus, $exactStatuses, true)) {
return true;
}
return str_starts_with($normalizedStatus, 'prosesing koloni media')
|| str_starts_with($normalizedStatus, 'processing koloni media')
|| str_starts_with($normalizedStatus, 'proses identifikasi')
|| str_starts_with($normalizedStatus, 'proses uji kepekaan');
}
public function earlyWarning(Request $request)
{
$this->requireUser($request);
@@ -104,7 +104,7 @@
if (trim($rows->komponen) == 'bakteri') {$bakteri = $rows->isidata; }
}
}
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -94,7 +94,7 @@
$namakuman = '';
$namakuman1 = '';
$namakuman2 = '';
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -353,7 +353,7 @@
$namakuman = '';
$namakuman1 = '';
$namakuman2 = '';
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -196,7 +196,7 @@
</table>
@php
$cekketerangan = explode('/table', $keterangan);
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -333,7 +333,7 @@
$namakuman = '';
$namakuman1 = '';
$namakuman2 = '';
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -178,7 +178,7 @@
$namakuman = '';
$namakuman1 = '';
$namakuman2 = '';
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
@@ -100,7 +100,7 @@
if (trim($rows->komponen) == 'iggigm_interpretasi') {$iggigm_interpretasi = $rows->isidata; }
}
}
if ($periksa->kd_spesimen == 'KULTUR JMR'){
if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){
$teksmikroorganisme = 'Jamur yang ditemukan : ';
} else {
$teksmikroorganisme = 'Bakteri yang ditemukan : ';
+3
View File
@@ -1,7 +1,10 @@
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:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
+17
View File
@@ -1,8 +1,25 @@
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;
};
}
}
@@ -16,6 +16,8 @@ class CciExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -31,47 +33,42 @@ class CciExpertiseWizard extends StatefulWidget {
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> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 3));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 3);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 3 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -339,60 +336,136 @@ class PatientStaffPanel extends StatelessWidget {
@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(12),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DetailRow(label: 'Order', value: item['reques']),
DetailRow(
label: 'Dokter Pengirim',
value: item['klinisi'] ?? item['nmdokter'],
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),
],
),
DetailRow(label: 'Asal Pasien', value: item['asalpasien']),
DetailRow(label: 'No. Lokal Lab', value: item['nofoto']),
DetailRow(label: 'Register Number', value: item['noregister']),
DetailRow(label: 'Name', value: item['nmpasien']),
DetailRow(
label: 'Gender, Age',
value: '${item['jkpasien'] ?? '-'}, ${item['usia'] ?? '-'}',
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,
),
],
),
DetailRow(label: 'Phone', value: item['tlppasien']),
DetailRow(label: 'Address', value: item['alamatpasien']),
DetailRow(
label: 'Tanggal Registrasi',
value: item['tanggalregis'] ?? item['daftar'],
const SizedBox(height: 8),
_InfoTile(
label: 'Address',
value: item['alamatpasien'],
icon: Icons.place_outlined,
),
DetailRow(
label: 'Tanggal Pengambilan',
value: item['tanggalsampel'],
),
DetailRow(
label: 'Cara Pengambilan',
value: item['pengambilan'],
),
DetailRow(
label: 'Asal Pengambilan',
value: item['asalpengirim'],
),
DetailRow(
label: 'Spesimen',
value: item['kesimpulan'] ?? item['nm_spesimen'],
),
DetailRow(label: 'Status', value: item['status']),
],
),
),
),
const SizedBox(height: 12),
_staffSelect('analis', 'ATLM', asList(staffOptions['analis'])),
_staffSelect('ppds3', 'PPDS', asList(staffOptions['ppds'])),
_staffSelect('dokter', 'SPV', asList(staffOptions['dokters'])),
DetailRow(label: 'Klinis/Diagnosis', value: item['klinis']),
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,
),
],
);
}
@@ -409,6 +482,7 @@ class PatientStaffPanel extends StatelessWidget {
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.account_circle_outlined),
),
items: [
const DropdownMenuItem(value: '0', child: Text('Pilih')),
@@ -426,6 +500,113 @@ class PatientStaffPanel extends StatelessWidget {
),
);
}
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 = [
@@ -15,6 +15,8 @@ class CovidExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -29,47 +31,42 @@ class CovidExpertiseWizard extends StatefulWidget {
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> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -136,49 +133,10 @@ class _CovidExpertiseWizardState extends State<CovidExpertiseWizard> {
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
File diff suppressed because it is too large Load Diff
+228 -181
View File
@@ -23,6 +23,7 @@ class _ExpertiseScreenState extends State<ExpertiseScreen> {
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;
@@ -89,6 +90,11 @@ class _ExpertiseScreenState extends State<ExpertiseScreen> {
);
}
}
for (final name in kAntibioticMediaRowFields) {
_textControllers[name] = TextEditingController(
text: components[name]?.toString() ?? '',
);
}
}
Future<void> _setTemplate(String dlp) async {
@@ -113,6 +119,9 @@ class _ExpertiseScreenState extends State<ExpertiseScreen> {
}
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) {
@@ -151,6 +160,12 @@ class _ExpertiseScreenState extends State<ExpertiseScreen> {
}
}
int _wizardStep(String dlp) => _wizardSteps[dlp] ?? 0;
void _setWizardStep(String dlp, int step) {
_wizardSteps[dlp] = step;
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
@@ -188,201 +203,233 @@ class _ExpertiseScreenState extends State<ExpertiseScreen> {
_prepareFields(data);
final isSupervisor = _isSupervisor(user['previlage']?.toString());
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
final summaryCard = Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
),
Text(item['reques']?.toString() ?? '-'),
const SizedBox(height: 8),
Row(
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
),
Text(item['reques']?.toString() ?? '-'),
const SizedBox(height: 8),
Row(
children: [
StatusPill(
status: item['status']?.toString() ?? 'NEW',
),
const SizedBox(width: 8),
if (_currentDlp.isNotEmpty)
BadgeLabel(
text: _currentDlp,
color: const Color(0xFF0F766E),
),
],
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
const SizedBox(width: 8),
if (_currentDlp.isNotEmpty)
BadgeLabel(
text: _currentDlp,
color: const Color(0xFF0F766E),
),
],
),
),
],
),
const SizedBox(height: 14),
if (_currentDlp.isEmpty)
TemplatePicker(
),
);
final expertiseContent = _currentDlp.isEmpty
? TemplatePicker(
templates: asList(data['templates']),
saving: _saving,
onUse: _setTemplate,
)
else
_currentDlp == 'CCI'
? CciExpertiseWizard(
: _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']),
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,
)
: _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,
)
: _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,
)
: _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,
)
: _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,
)
: _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,
)
: _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,
)
: 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,
),
],
),
],
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.all(16),
children: [
summaryCard,
const SizedBox(height: 14),
expertiseContent,
],
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
summaryCard,
const SizedBox(height: 14),
Expanded(child: expertiseContent),
],
),
);
},
),
@@ -17,6 +17,8 @@ class KulturExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -33,47 +35,42 @@ class KulturExpertiseWizard extends StatefulWidget {
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> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 5));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 5);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 5 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -172,10 +169,32 @@ class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
_options('jsonpewarnaangiemsa', _giemsaFallback),
),
_text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'),
_saveDirectSmearButton(),
],
);
}
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),
@@ -276,53 +295,10 @@ class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('C. Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
_mediaStatusSummary(),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _mediaStatusSummary() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
@@ -512,32 +488,14 @@ class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
@@ -572,24 +530,10 @@ class _KirbyAntibiotic {
final String sirField;
}
class _CultureMedia {
const _CultureMedia(this.label, this.statusField, this.noteField);
final String label;
final String statusField;
final String noteField;
}
const List<String> _scoreOptions = ['0', '1', '2', '3', '4'];
const List<String> _antibioticSirOptions = ['S', 'I', 'R'];
const List<String> _growthOptions = [
'Ada Pertumbuhan',
'Tidak Ada Pertumbuhan',
'Pertumbuhan Primer',
];
const List<String> _kirbyZoneOptions = [
'Tidak dilakukan',
'1',
@@ -643,30 +587,6 @@ const List<_KirbyAntibiotic> _kirbyAntibiotics = [
_KirbyAntibiotic('SCF', 'id_kbscf', 'id_sirkbscf'),
];
const List<_CultureMedia> _cultureMedia = [
_CultureMedia('Media BAP', 'media_bap_status', 'media_bap_note'),
_CultureMedia('Media CAP', 'media_cap_status', 'media_cap_note'),
_CultureMedia(
'Media Mc Conkey',
'media_mcconkey_status',
'media_mcconkey_note',
),
_CultureMedia('Kultur Jamur R1', 'media_sdar1_status', 'media_sdar1_note'),
_CultureMedia('Kultur Jamur R2', 'media_sdar2_status', 'media_sdar2_note'),
_CultureMedia('Kultur Jamur I1', 'media_sdai1_status', 'media_sdai1_note'),
_CultureMedia('Kultur Jamur I2', 'media_sdai2_status', 'media_sdai2_note'),
_CultureMedia(
'Media Selektif Lainnya',
'media_sellainnya_status',
'media_sellainnya_note',
),
_CultureMedia(
'Pemeriksaan Tambahan Lainnya',
'media_tamlainnya_status',
'media_tamlainnya_note',
),
];
const List<String> _kohFallback = [
'Ditemukan morfologi Hifa',
'Ditemukan morfologi Budding Cell',
@@ -15,6 +15,8 @@ class LeptospiraExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -29,6 +31,8 @@ class LeptospiraExpertiseWizard extends StatefulWidget {
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<LeptospiraExpertiseWizard> createState() =>
@@ -36,41 +40,34 @@ class LeptospiraExpertiseWizard extends StatefulWidget {
}
class _LeptospiraExpertiseWizardState extends State<LeptospiraExpertiseWizard> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -126,49 +123,10 @@ class _LeptospiraExpertiseWizardState extends State<LeptospiraExpertiseWizard> {
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
@@ -17,6 +17,8 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -33,6 +35,8 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget {
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<PewarnaanLangsungExpertiseWizard> createState() =>
@@ -41,41 +45,34 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget {
class _PewarnaanLangsungExpertiseWizardState
extends State<PewarnaanLangsungExpertiseWizard> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -175,10 +172,32 @@ class _PewarnaanLangsungExpertiseWizardState
maxLines: 8,
),
_nugentScore(),
_saveDirectSmearButton(),
],
);
}
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),
@@ -214,49 +233,10 @@ class _PewarnaanLangsungExpertiseWizardState
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
@@ -440,32 +420,14 @@ class _PewarnaanLangsungExpertiseWizardState
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
@@ -16,6 +16,8 @@ class TbcExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -31,47 +33,42 @@ class TbcExpertiseWizard extends StatefulWidget {
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> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 6));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 6);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 6 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -362,38 +359,10 @@ class _TbcExpertiseWizardState extends State<TbcExpertiseWizard> {
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
@@ -567,32 +536,14 @@ class _TbcExpertiseWizardState extends State<TbcExpertiseWizard> {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
child: CompactMultiSelectField(
label: label,
options: options,
selected: selected,
onChanged: (value) {
widget.multiValues[name] = value;
widget.onChanged();
},
),
);
}
@@ -16,6 +16,8 @@ class ViralLoadExpertiseWizard extends StatefulWidget {
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
required this.initialStep,
required this.onStepChanged,
});
final Map<String, dynamic> item;
@@ -31,6 +33,8 @@ class ViralLoadExpertiseWizard extends StatefulWidget {
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
final int initialStep;
final ValueChanged<int> onStepChanged;
@override
State<ViralLoadExpertiseWizard> createState() =>
@@ -38,41 +42,34 @@ class ViralLoadExpertiseWizard extends StatefulWidget {
}
class _ViralLoadExpertiseWizardState extends State<ViralLoadExpertiseWizard> {
int _step = 0;
late int _step;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
@override
void initState() {
super.initState();
_step = widget.initialStep.clamp(0, 4);
}
void _previous() {
setState(() => _step = (_step - 1).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 Stepper(
return ExpertiseWizardShell(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
onStepChanged: _setStep,
steps: [
Step(
title: const Text('Pasien & Petugas'),
@@ -139,49 +136,10 @@ class _ViralLoadExpertiseWizardState extends State<ViralLoadExpertiseWizard> {
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
return AntibioticSensitivityPanel(
textControllers: widget.textControllers,
selectValues: widget.selectValues,
onChanged: widget.onChanged,
);
}
+51 -26
View File
@@ -64,8 +64,10 @@ class _LoginScreenState extends State<LoginScreen> {
);
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Tidak bisa terhubung ke server MyLIS.');
} 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);
}
@@ -93,8 +95,10 @@ class _LoginScreenState extends State<LoginScreen> {
});
} on ApiException catch (error) {
setState(() => _pingStatus = error.message);
} catch (_) {
setState(() => _pingStatus = 'Koneksi server gagal.');
} 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);
}
@@ -120,29 +124,34 @@ class _LoginScreenState extends State<LoginScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Image.asset(
kAppLogoAsset,
height: 96,
fit: BoxFit.contain,
),
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: 16),
Text(
kAppLongName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
Center(
child: Image.asset(
kHospitalLogoAsset,
height: 54,
fit: BoxFit.contain,
),
),
const SizedBox(height: 8),
const SizedBox(height: 18),
Text(
kHospitalName,
textAlign: TextAlign.center,
@@ -152,6 +161,13 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
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,
@@ -261,6 +277,15 @@ class _LoginScreenState extends State<LoginScreen> {
context,
).textTheme.bodySmall?.copyWith(color: Colors.black45),
),
const SizedBox(height: 18),
Text(
'Pembuat aplikasi: Duidev Software House | CV Swandhana Copyright @ 2026',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.black54,
fontWeight: FontWeight.w600,
),
),
],
),
),
+156 -16
View File
@@ -25,31 +25,171 @@ class ApiClient {
String path,
Map<String, dynamic> body,
) async {
final response = await http.post(
_uri(path),
headers: _headers,
body: jsonEncode(body),
);
return _decode(response);
final uri = _uri(path);
final client = _clientFor(uri);
try {
final response = await client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
return _decode(response, uri);
} catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace);
} finally {
client.close();
}
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
final response = await http.get(_uri(path, query), headers: _headers);
return _decode(response);
final uri = _uri(path, query);
final client = _clientFor(uri);
try {
final response = await client
.get(uri, headers: _headers)
.timeout(const Duration(seconds: 15));
return _decode(response, uri);
} catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace);
} finally {
client.close();
}
}
Map<String, dynamic> _decode(http.Response response) {
final decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded is Map<String, dynamic> ? decoded : {'data': decoded};
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',
);
}
final message = decoded is Map ? decoded['message'] : null;
throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.');
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}.',
);
}
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;
}
}
+29
View File
@@ -138,3 +138,32 @@ List<dynamic> asList(Object? value) => value is List ? value : <dynamic>[];
String _message(Object? error) => error is ApiException
? error.message
: 'Terjadi kesalahan saat memuat data.';
String decodeHtmlEntities(String value) {
var result = value;
for (var i = 0; i < 3; i += 1) {
final previous = result;
result = result
.replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) {
final code = int.tryParse(match.group(1) ?? '');
return code == null ? match.group(0)! : String.fromCharCode(code);
});
result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) {
final code = int.tryParse(match.group(1) ?? '', radix: 16);
return code == null ? match.group(0)! : String.fromCharCode(code);
});
if (result == previous) {
break;
}
}
return result;
}