diff --git a/htdocs/app/Http/Controllers/JsonTransferController.php b/htdocs/app/Http/Controllers/JsonTransferController.php index edde736a..5cbcd500 100644 --- a/htdocs/app/Http/Controllers/JsonTransferController.php +++ b/htdocs/app/Http/Controllers/JsonTransferController.php @@ -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); diff --git a/listener/app.py b/listener/app.py deleted file mode 100644 index f27acd7f..00000000 --- a/listener/app.py +++ /dev/null @@ -1,3266 +0,0 @@ -import builtins -from enum import Enum -from logging import config -from logging.handlers import TimedRotatingFileHandler -import os -from queue import Queue -import re -import socket -import logging -from sqlite3 import Date -import threading -import time -import datetime -import traceback -import serial # type: ignore - -from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text, func # type: ignore -from sqlalchemy import DateTime as SqDateTime # type: ignore -from sqlalchemy import Date as SqDate # type: ignore -from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore -# Logging Setup - -def get_app_dir(): - """ - Mengembalikan direktori aplikasi. - - Python: - folder tempat app.py berada - - PyInstaller EXE: - folder tempat app.exe berada - """ - if getattr(sys, "frozen", False): - return os.path.dirname(os.path.abspath(sys.executable)) - - return os.path.dirname(os.path.abspath(__file__)) - - -APP_DIR = get_app_dir() - -THREAD_LOG_DIR = os.path.join(APP_DIR, "thread_logs") -APP_LOG_FILE = os.path.join(APP_DIR, "app.log") - -thread_log_lock = threading.Lock() - - -# Buat folder sejak startup -try: - os.makedirs(THREAD_LOG_DIR, exist_ok=True) -except Exception as exc: - builtins.print( - f"[LOG-INIT-ERROR] Tidak bisa membuat " - f"{THREAD_LOG_DIR}: {exc}" - ) - - -# Logging Setup -log_handler = TimedRotatingFileHandler( - filename=APP_LOG_FILE, - when="midnight", - interval=1, - backupCount=7, - encoding="utf-8" -) - -formatter = logging.Formatter( - '%(asctime)s - %(levelname)s - %(threadName)s - %(message)s' -) - -log_handler.setFormatter(formatter) -log_handler.setLevel(logging.ERROR) - -logging.basicConfig( - level=logging.ERROR, - handlers=[log_handler] -) - -logging.getLogger().setLevel(logging.ERROR) -logging.getLogger("werkzeug").setLevel(logging.ERROR) -thread_log_lock = threading.Lock() -ERROR_LOG_KEYWORDS = ( - "critical", - "crash", - "error", - "exception", - "failed", - "failure", - "gagal", - "corrupt", - "ditolak", - "traceback", -) - -# ========================================== -# 1. KONFIGURASI SISTEM -# ========================================== -# Global Variables -active_genexpert_connections = {} -connection_lock = threading.Lock() -# Network Configuration -TCP_LISTENER_PORT = 6001 # PC GeneXpert set ke mode Client, konek ke IP:PORT ini -SERVER_HOST = '0.0.0.0' # Listen di semua interface -GENEXPERT_RESPONSE_MODE_DEFAULT = "astm_active" -GENEXPERT_RESPONSE_MODE_BY_IP = { - # "10.10.120.75": "hl7_passive", -} -GENEXPERT_HOST_APPLICATION_DEFAULT = "DE002" -GENEXPERT_HOST_APPLICATION_BY_IP = { - "10.10.120.75": "GE01", # GenExpert Kecil - "10.10.120.108": "DE002", # GenExpert Tempat Lama - "10.10.120.73": "GE01", # GenExpert Besar -} -# Mapping Flag ke IP Address GeneXpert -# Pastikan IP ini SESUAI dengan settingan "Server IP" di masing-masing alat (Client Mode) -TARGET_MAPPING = { - 'flg_gxp1': '10.10.120.73', - 'flg_gxp2': '10.10.120.108', - 'flg_gxp3': '10.10.120.75' -} -GENEXPERT_ORDER_LOCKS = { - "10.10.120.73": threading.Lock(), - "10.10.120.108": threading.Lock(), - "10.10.120.75": threading.Lock(), -} -# GeneXpert Configuration -# ========================================== -GENEXPERT_TEST_MAPPING = { - "HIV": "HIV1-VL", - "HBV": "HBVVL", - "TCM TB": "MTBRIF", - "TCM TB ULTRA": "MTBRIF", - "TCM TB XDR": "MTB-XDR 2", - "HCV VL": "HCV", - "COVID-19": "SARSCOV2FLURSV", - "17.3.1 TCM COVID-19": "SARSCOV2FLURSV", - "17.3.2 PCR COVID-19": "SARSCOV2FLURSV", - "E.2.5 HCV TCM": "HCV", - "18.1.1 TCM HCV": "HCV", - "18.1.2 TCM HIV VIRAL LOAD": "HIV1-VL", - "18.1.4 TCM HPV": "HCV", - "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", - "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", - "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR 2", - "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "1.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "1.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "15.2.1 KULTUR TB MEDIA LJ": "MTB-XDR 2", - "8.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "9.3.5 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "9.3.6 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "12.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "H.2.5 PEMERIKSAAN KULTUR MYCROBACTERIUM TBC": "MTB-XDR 2", - "12.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "11.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "10.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "10.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "3.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "15.2.2 KULTUR TB MEDIA MGIT (AUTOMATIC)": "MTB-XDR 2", - "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTBRIF", - "12.3.8 TCM TB (GENE EXPERT)": "MTBRIF", - "15.2.3 TCM TB (GENE EXPERT)": "MTBRIF", - "15.2.3 TCM TB (GENE EXPERT) GENE EXPERT": "MTBRIF", - "2.3.9 TCM GENE EXPERT": "MTBRIF", - "3.3.8 TCM GENE EXPERT": "MTBRIF", - "3.3.9 TCM MYCOBACTERIUM TUBERCULOSIS": "MTBRIF", - "5.3.9 TCM TBC (GENE EXPERT)": "MTBRIF", - "7.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "8.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "10.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "11.3.9 TCM TB (GENE EXPERT)": "MTBRIF", -} -GENEXPERT_IP_CAPABILITIES = { - "10.10.120.75": ["MTBRIF", "HBVVL", "HIV1-VL", "MTB-XDR 2", "HCV", "SARSCOV2FLURSV"], - "10.10.120.73": ["MTBRIF", "HBVVL", "HIV1-VL", "HCV", "SARSCOV2FLURSV"], - "10.10.120.108": ["MTBRIF", "HCV", "HIV1-VL", "HBVVL", "SARSCOV2FLURSV"], -} -DEFAULT_GXP_CODE = "MTBRIF" - -DEVICE_CONFIGS = [ - { - 'port': 'COM6', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 1', - 'protocol': 'serial', 'flag_column': 'flg_vitek1' - }, - #{ - # 'port': 'COM4', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 2', - # 'protocol': 'serial', 'flag_column': 'flg_vitek2' - #}, - { - 'port': 'COM5', 'baud_rate': 9600, 'device_type': 'bd', 'alat_name': 'BACTEC', - 'protocol': 'serial', 'flag_column': 'flg_bd1' - }, - #BD_MGIT yang di dalam ruangan isolasi - #{ - # 'port': 'COM4', 'baud_rate': 19200, 'device_type': 'bd', 'alat_name': 'MGIT', - # 'protocol': 'serial', 'flag_column': 'flg_bd2' - #}, -] -MYLA_HOST = '10.10.120.89' -MYLA_PORT = 8000 -MYLA_INBOUND_HOST = '0.0.0.0' -MYLA_INBOUND_PORT = 8000 -MYLA_POLL_INTERVAL_SECONDS = 1 -MYLA_CONNECT_RETRY_SECONDS = 5 -MYLA_CONTROL_TIMEOUT_SECONDS = 6 -MYLA_ACK_TIMEOUT_SECONDS = 8 -MYLA_IDLE_LOG_INTERVAL_SECONDS = 30 - -# Karakter kontrol standar -STX, ETX, ACK, NAK, EOT, ENQ = b'\x02', b'\x03', b'\x06', b'\x15', b'\x04', b'\x05' -RS, GS = b'\x1e', b'\x1d' -ports_lock = threading.Lock() -active_serial_ports = {} - -order_queues = {config['port']: Queue() for config in DEVICE_CONFIGS if config['protocol'] == 'serial'} - -# ========================================== -# 2. DATABASE CONNECTION -# ========================================== -DATABASE_URL = "postgresql://lismikro:lismikro@10.10.123.193:5002/lismikro" -engine = create_engine(DATABASE_URL, pool_recycle=3600) -SessionLocal = sessionmaker(bind=engine) -Base = declarative_base() - -# ========================================== -# 2. DATABASE MODEL -# ========================================== -class Sample(Base): - __tablename__ = 'samples' - id = Column(Integer, primary_key=True) - patient_name = Column(String(100)) - patient_id = Column(String(50)) - sample_id = Column(String(50), unique=True) - test_type = Column(String(100)) - result = Column(Text) - raw_message = Column(Text) - created_at = Column(SqDateTime, default=datetime.datetime.now) - -class SerialOrderQueue(Base): - __tablename__ = 'serial_order_queue' - id = Column(Integer, primary_key=True) - target_port = Column(String(50), nullable=False, index=True) - message_to_send = Column(Text, nullable=False) - status = Column(String(20), default='pending', index=True) - created_at = Column(SqDateTime, default=datetime.datetime.now) - updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) - -class LisPhoenix(Base): - __tablename__ = 'lis_phoenix' - id = Column(Integer, primary_key=True) - no_id = Column(String(50)) # Patient ID - seq_no = Column(String(50)) # Isolate ID / Sample ID - rnmpas = Column(String(100)) # Patient Name - tgl_data = Column(SqDate) - rawdt = Column(Text) - organisme = Column(String(100)) - kd_orgm = Column(String(50)) - alat = Column(String(50)) - processed = Column(String(50), nullable=True) - -class LisPhoenixDtl(Base): - __tablename__ = 'lis_phoenix_dtl' - id = Column(Integer, primary_key=True) - seq_no = Column(String(50)) - kd_antibiotik = Column(String(50)) - nm_antibiotik = Column(String(100)) - keterangan = Column(String(50)) - interpretasi = Column(String(10)) # S, I, R - no = Column(Integer) - -class PaslabOrder(Base): - __tablename__ = 'paslab' - urut = Column(Integer, primary_key=True) - rnoreg = Column(String) - nama = Column(String) - norm = Column(String) - rjenis = Column(String) - rtglast = Column(SqDateTime) - alamat = Column(String) - umur = Column(String) - namadok = Column(String) - ruangan = Column(String) - tes = Column(String) - alat = Column(String) - kd_spesimen = Column(String) - nm_spesimen = Column(String) - tgllahir = Column(SqDate) - flg_vitek1 = Column(Boolean, default=False) - flg_vitek2 = Column(Boolean, default=False) - flg_bd1 = Column(Boolean, default=False) - flg_bd2 = Column(Boolean, default=False) - flg_gxp1 = Column(Boolean, default=False) - flg_gxp2 = Column(Boolean, default=False) - flg_gxp3 = Column(Boolean, default=False) - flg_vitek3 = Column(Boolean, default=False) - created_at = Column(SqDateTime, default=datetime.datetime.now) - updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) - -Base.metadata.create_all(bind=engine) - -# ========================================== -# 3. HL7 HELPER FUNCTIONS -# ========================================== -def _sanitize_thread_log_name(thread_name): - safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(thread_name or "main").strip()) - return safe_name or "main" - -def _write_thread_log(message): - text = str(message or "") - if not any(keyword in text.lower() for keyword in ERROR_LOG_KEYWORDS): - return - - try: - os.makedirs(THREAD_LOG_DIR, exist_ok=True) - thread_name = threading.current_thread().name - safe_thread_name = _sanitize_thread_log_name(thread_name) - log_path = os.path.join(THREAD_LOG_DIR, f"{safe_thread_name}.log") - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with thread_log_lock: - with open(log_path, "a", encoding="utf-8") as fh: - fh.write(f"{timestamp} | {message}\n") - except Exception as exc: - builtins.print( - f"[THREAD-LOG-ERROR] " - f"Gagal menulis thread log ke " - f"{THREAD_LOG_DIR}: {exc}" - ) - -def print(*args, **kwargs): - sep = kwargs.get("sep", " ") - end = kwargs.get("end", "\n") - message = sep.join(str(arg) for arg in args) - _write_thread_log(message) - return builtins.print(*args, **kwargs) - -def _visible_bytes(data: bytes) -> str: - mapping = { - 0x02: "", - 0x03: "", - 0x04: "", - 0x05: "", - 0x06: "", - 0x0D: "", - 0x0A: "", - 0x17: "", - 0x15: "", - 0x1C: "", - 0x0B: "", - } - parts = [] - for b in data: - if b in mapping: - parts.append(mapping[b]) - elif 32 <= b <= 126: - parts.append(chr(b)) - else: - parts.append(f"<0x{b:02X}>") - return "".join(parts) - -def _hex_bytes(data: bytes, limit: int = 160) -> str: - clipped = data[:limit] - text = clipped.hex().upper() - return text + ("..." if len(data) > limit else "") - -def get_flag_by_device(ip_addr): - for flag, ip in TARGET_MAPPING.items(): - if ip == ip_addr: - return flag - return None - -def mark_genexpert_order_flag(rnoreg, ip_addr, reason="processed"): - rnoreg = str(rnoreg or "").strip() - flag_name = get_flag_by_device(str(ip_addr or "").strip()) - if not rnoreg: - print(f"[GENEXPERT-DB] Skip update flag, rnoreg kosong. reason={reason}") - return False - if not flag_name: - print(f"[GENEXPERT-DB] Skip update flag rnoreg={rnoreg}, IP {ip_addr} tidak terdaftar.") - return False - - flag_attr = getattr(PaslabOrder, flag_name, None) - if flag_attr is None: - print(f"[GENEXPERT-DB] Skip update flag rnoreg={rnoreg}, kolom {flag_name} tidak ada.") - return False - - with SessionLocal() as session: - try: - match_filter = func.trim(PaslabOrder.rnoreg) == rnoreg - matched_count = session.query(PaslabOrder).filter(match_filter).count() - if matched_count == 0: - print(f"[GENEXPERT-DB] Order {rnoreg} tidak ditemukan saat update {flag_name}. reason={reason}") - return False - - updated_count = session.query(PaslabOrder).filter(match_filter).update( - { - flag_attr: True, - PaslabOrder.updated_at: datetime.datetime.now(), - }, - synchronize_session=False, - ) - session.commit() - - true_count = session.query(PaslabOrder).filter(match_filter, flag_attr == True).count() - if true_count == matched_count: - print( - f"[GENEXPERT-DB] Order {rnoreg} set {flag_name}=TRUE. " - f"rows={updated_count}/{matched_count}, reason={reason}" - ) - return True - - print( - f"[GENEXPERT-DB-WARN] Update {flag_name} belum terverifikasi rnoreg={rnoreg}. " - f"true_rows={true_count}/{matched_count}, updated_rows={updated_count}, reason={reason}" - ) - return False - except Exception as exc: - session.rollback() - print(f"[GENEXPERT-DB-ERROR] Gagal update {flag_name} rnoreg={rnoreg}, reason={reason}, error={exc}") - return False - -def parse_hl7_segments(hl7_message): - return [segment for segment in str(hl7_message or "").split('\r') if segment] - -def extract_segment(hl7_message, segment_name): - prefix = f"{segment_name}|" - for segment in parse_hl7_segments(hl7_message): - if segment.startswith(prefix): - return segment - return "" - -def format_hl7_date(value): - if not value: - return "" - if isinstance(value, datetime.datetime): - return value.strftime("%Y%m%d") - if isinstance(value, datetime.date): - return value.strftime("%Y%m%d") - text = str(value).strip() - digits = re.sub(r"[^0-9]", "", text) - return digits[:8] if len(digits) >= 8 else "" - -def format_hl7_datetime(value): - if not value: - return "" - if isinstance(value, datetime.datetime): - return value.strftime("%Y%m%d%H%M%S") - if isinstance(value, datetime.date): - return value.strftime("%Y%m%d") - text = str(value).strip() - digits = re.sub(r"[^0-9]", "", text) - if len(digits) >= 14: - return digits[:14] - return digits[:8] if len(digits) >= 8 else "" - -def build_hl7_segment(segment_name, fields): - max_field = max(fields.keys()) if fields else 0 - values = [str(fields.get(index, "")) for index in range(1, max_field + 1)] - return f"{segment_name}|" + "|".join(values) - -def extract_msg_control_id(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 9: - return msh[9].strip() - return None - except: - return None - -def extract_message_type(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 8: - return msh[8].strip() - return "" - except: - return "" - -def build_hl7_preview(hl7_message, max_segments=4): - try: - segments = [segment.strip() for segment in str(hl7_message or "").split('\r') if segment.strip()] - preview = " | ".join(segments[:max_segments]) - return preview[:800] - except Exception: - return str(hl7_message or "")[:800] - -# ========================================== -# geneXpert TCP Server & HL7/ASTM Handler -# ========================================== - -def manage_tcp_server(): - """Thread Server Utama untuk GeneXpert""" - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # Allow reuse address agar tidak error 'Address already in use' saat restart - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - server.bind((SERVER_HOST, TCP_LISTENER_PORT)) - server.listen(5) # Bisa antri 5 koneksi - print(f"[TCP-SERVER] Listening GeneXpert di port {TCP_LISTENER_PORT}...") - while True: - # Accept koneksi baru (Blocking, tapi aman karena di thread sendiri) - client_sock, addr = server.accept() - - # Buat thread kecil untuk handle client tersebut (agar server bisa terima client lain) - client_thread = threading.Thread( - target=handle_genexpert_client, - args=(client_sock, addr), - daemon=True - ) - client_thread.start() - - except Exception as e: - logging.critical(f"[TCP-SERVER] Gagal Start: {e}") - print(f"[TCP-SERVER] Gagal Start: {e}") - -def get_genexpert_host_application(ip_addr): - ip_addr = str(ip_addr or "").strip() - host_app = GENEXPERT_HOST_APPLICATION_BY_IP.get(ip_addr, GENEXPERT_HOST_APPLICATION_DEFAULT) - host_app = str(host_app or "").strip() - return host_app or GENEXPERT_HOST_APPLICATION_DEFAULT - -def parse_astm_records(message_text): - records = [] - for rec in str(message_text or "").split("\r"): - rec = rec.strip() - if not rec: - continue - if rec and rec[0].isdigit(): - rec = rec[1:] - records.append(rec) - return records - -def parse_genexpert_astm_query(message_text): - records = parse_astm_records(message_text) - query = { - "query_sample_id": "", - "query_tag": "", - "raw_records": records, - } - for rec in records: - fields = rec.split("|") - if not fields: - continue - if fields[0] == "H": - query["query_tag"] = fields[4] if len(fields) > 4 else "" - elif fields[0] == "Q": - query["query_sample_id"] = fields[2] if len(fields) > 2 else "" - return query - -def summarize_genexpert_astm_orders(records): - summaries = [] - for rec in records: - if not rec.startswith("O|"): - continue - - fields = rec.split("|") - sample_id = fields[2].strip() if len(fields) > 2 else "" - container_id = fields[3].strip() if len(fields) > 3 else "" - assay_code = "" - if len(fields) > 4: - assay_parts = [part.strip() for part in fields[4].split("^") if part.strip()] - assay_code = assay_parts[-1] if assay_parts else fields[4].strip() - priority = fields[5].strip() if len(fields) > 5 else "" - action_code = fields[11].strip() if len(fields) > 11 else "" - - summaries.append( - f"sample_id={sample_id or '-'}, container_id={container_id or '-'}, " - f"assay={assay_code or '-'}, priority={priority or '-'}, action={action_code or '-'}" - ) - return summaries - -def create_genexpert_astm_order_message(orders, ip_addr=None, query_tag=""): - timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") - records = [ - f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{timestamp}" - ] - - for index, order in enumerate(orders, start=1): - patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=32) - sample_id = sanitize_astm_field(order.rnoreg, max_len=32) - assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - print(f"[GENEXPERT] ASTM payload order dilewati rnoreg={sample_id} karena assay kosong.") - continue - - first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) - first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) - last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) - patient_name = f"{last_name}^{first_name}".strip("^") - sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) - sex = "M" if sex_raw.startswith("L") else ("F" if sex_raw else "") - order_ts = ( - getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') - if getattr(order, "rtglast", None) - else timestamp - ) - - print( - f"[GENEXPERT-ASTM-ORDER] rnoreg={sample_id}, ip={ip_addr}, patient_id={patient_id}, " - f"assay_code={assay_code}, assay_source={assay_source}, capability_match={capability_match}" - ) - - records.append(f"P|{index}|{patient_name}||{patient_id}|{patient_name}|||{sex}") - records.append(f"O|1|{sample_id}||^^^{assay_code}|R|{order_ts}|||||||||ORH||||||||||A") - - records.append("L|1|N") - message = "\r".join(records) + "\r" - print(f"[GENEXPERT-ASTM-ORDER] ip={ip_addr}, query_tag={query_tag}, records={len(records)}, visible={_visible_bytes(message.encode('latin-1'))}") - return message - -def send_all_orders_astm(conn, ip_addr, astm_msg, response_framing="astm"): - lock = GENEXPERT_ORDER_LOCKS.setdefault(ip_addr, threading.Lock()) - - print(f"[GENEXPERT-LOCK] ip={ip_addr}, waiting") - - with lock: - print(f"[GENEXPERT-LOCK] ip={ip_addr}, acquired") - - query = parse_genexpert_astm_query(astm_msg) - requested_sample_id = str(query.get("query_sample_id") or "").strip() - query_tag = str(query.get("query_tag") or "").strip() - - flag = get_flag_by_device(ip_addr) - - if not flag: - print( - f"[GENEXPERT] ASTM query diabaikan, " - f"flag untuk {ip_addr} tidak ditemukan." - ) - return - - print( - f"[GENEXPERT-ORDER-SELECT] " - f"ip={ip_addr}, flag={flag}, " - f"requested_sample_id='{requested_sample_id}'" - ) - - session = SessionLocal() - - try: - flag_attr = getattr(PaslabOrder, flag, None) - - if flag_attr is None: - print( - f"[GENEXPERT] ASTM query diabaikan, " - f"atribut flag {flag} tidak ada." - ) - return - - base_orders = ( - session.query(PaslabOrder) - .filter( - (flag_attr == False) | (flag_attr == None) - ) - .order_by( - PaslabOrder.rtglast.desc().nullslast(), - PaslabOrder.urut.desc() - ) - .all() - ) - - selected_orders = [] - - if requested_sample_id and requested_sample_id.upper() != "ALL": - - for order in base_orders: - - if str(order.rnoreg or "").strip() != requested_sample_id: - continue - - assay_code, _, _ = resolve_genexpert_assay( - order, - ip_addr - ) - - if assay_code: - selected_orders = [order] - break - - else: - - for order in base_orders: - - assay_code, _, _ = resolve_genexpert_assay( - order, - ip_addr - ) - - if assay_code: - selected_orders = [order] - break - - print( - f"[GENEXPERT-ASTM-QUERY] " - f"ip={ip_addr}, " - f"flag={flag}, " - f"query_tag={query_tag}, " - f"requested_sample_id='{requested_sample_id}', " - f"selected_rnoreg=" - f"{[str(order.rnoreg or '').strip() for order in selected_orders]}" - ) - - if not selected_orders: - - reply = ( - f"H|\\^&|||" - f"{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}" - f"|||||GeneXpert Host||P|1394-97|" - f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - f"\rL|1|N\r" - ) - - send_genexpert_response( - conn, - ip_addr, - reply, - response_framing, - label="astm-q-empty" - ) - - return - - reply = create_genexpert_astm_order_message( - selected_orders, - ip_addr=ip_addr, - query_tag=query_tag - ) - - rnoreg_first = str( - selected_orders[0].rnoreg or "" - ).strip() - - sent_ok = send_genexpert_response( - conn, - ip_addr, - reply, - response_framing, - label=f"astm-q-order:{rnoreg_first}" - ) - - for order in selected_orders: - - rnoreg = str(order.rnoreg or "").strip() - - print( - f"[GENEXPERT] Order ASTM ditawarkan ke " - f"{ip_addr}: {rnoreg}, sent_ok={sent_ok}" - ) - - if sent_ok: - - flag_ok = mark_genexpert_order_flag( - rnoreg, - ip_addr, - reason="astm-order-sent" - ) - - print( - f"[GENEXPERT-ORDER-COMMIT] " - f"ip={ip_addr}, " - f"rnoreg={rnoreg}, " - f"flag={flag}, " - f"success={flag_ok}" - ) - - finally: - session.close() - - print(f"[GENEXPERT-LOCK] ip={ip_addr}, released") - -def process_genexpert_hl7_message(conn, ip_addr, clean_hl7, response_framing): - # ========================================================== - # 1. BLOK PENANGANAN ASTM (Karena tidak diawali "MSH|") - # ========================================================== - if not str(clean_hl7 or "").startswith("MSH|"): - records = parse_astm_records(clean_hl7) - record_types = [rec.split("|", 1)[0] for rec in records if rec] - print(f"[GENEXPERT-ASTM] ip={ip_addr}, record_types={record_types}") - - # A. Cek Jika Alat Meminta Order (Query) - if any(rec.startswith("Q|") for rec in records): - print(f"[GENEXPERT-ASTM] Alat meminta ORDER (Q Record)") - send_all_orders_astm(conn, ip_addr, clean_hl7, response_framing="astm") - return - - # B. Cek Jika Alat Mengirim Hasil Lab (Result) - if any(rec.startswith("R|") for rec in records): - print(f"[RESULT] Menerima Hasil Lab ASTM dari {ip_addr}.") - # Memanggil fungsi parser Anda untuk menyimpan hasil ke DB - parse_genexpert_astm_records(clean_hl7, device_name=f"GeneXpert-{ip_addr}") - return - - # C. [PERBAIKAN] Cek Jika Alat Mengirim Komentar/Penolakan (Comment) - if any(rec.startswith("C|") for rec in records): - # 1. Ekstrak teks komentar untuk ditampilkan di log - comments = [rec for rec in records if rec.startswith("C|")] - for c in comments: - parts = c.split('|') - comment_text = parts[3] if len(parts) > 3 else c - print(f"[GENEXPERT-ASTM-INFO] Komentar dari Alat: {comment_text}") - - # 2. Ekstrak NoReg (Nomor Order) dari record 'O' - rnoreg = None - for rec in records: - if rec.startswith("O|"): - o_parts = rec.split('|') - if len(o_parts) > 2: - rnoreg = o_parts[2].strip() - break - - # 3. Update Database PaslabOrder - if rnoreg: - mark_genexpert_order_flag(rnoreg, ip_addr, reason="astm-comment-duplicate-or-rejected") - - print(f"[GENEXPERT-ASTM] Transaksi penolakan order selesai diproses.") - return - - # D. Jika hanya berisi H dan L tanpa ada transaksi berarti (Status Echo) - if set(record_types).issubset({'H', 'L'}): - print(f"[GENEXPERT-ASTM] Menerima Heartbeat / Sesi Kosong dari alat.") - return - - # E. GeneXpert dapat mengirim status/echo order ASTM berisi H/P/O/L. - # Ini bukan hasil lab karena tidak ada R record, dan bukan query karena tidak ada Q record. - if "O" in record_types and set(record_types).issubset({'H', 'P', 'O', 'L'}): - order_summaries = summarize_genexpert_astm_orders(records) - print( - f"[GENEXPERT-ASTM] Menerima status/echo order tanpa hasil dari {ip_addr}. " - f"orders={order_summaries}" - ) - return - - log_genexpert_hl7("GENEXPERT-ASTM", ip_addr, clean_hl7, label="pesantidakdikenali") - print(f"[GENEXPERT-ASTM] Pesan ASTM tidak dikenali dari {ip_addr}. Isi: {clean_hl7[:50]}") - return - - # ========================================================== - # 2. BLOK PENANGANAN HL7 (Fallback / Cadangan) - # ========================================================== - log_genexpert_hl7("IN", ip_addr, clean_hl7) - lines = clean_hl7.split('\r') - msh_fields = lines[0].split('|') - incoming_control_id = msh_fields[9] if len(msh_fields) > 9 else "UNKNOWN" - msg_id = incoming_control_id - - if "QRY^" in clean_hl7 or "QRY|" in clean_hl7: - print( - f"[GENEXPERT] Legacy QRY diterima dari {ip_addr} tetapi diabaikan. " - "Host hanya mendukung QBP^Z01/QBP^Z03 untuk order query." - ) - return - - if "ORU^" in clean_hl7: - # --- [PATCH] CEK APAKAH INI PESAN ERROR / PENOLAKAN --- - if "|Error^" in clean_hl7 or "|X\r" in clean_hl7 or "\rTE|" in clean_hl7: - print(f"[GENEXPERT-ERROR] Mesin {ip_addr} menolak tes (Test Unknown/Disabled).") - - # Coba ekstrak NoReg dari segmen SPM agar kita bisa mengunci ordernya (set Flag = True) - rnoreg_error = None - for line in clean_hl7.split('\r'): - if line.startswith('SPM|'): - parts = line.split('|') - if len(parts) > 2: - rnoreg_error = parts[2].replace('^', '').strip() - break - - if rnoreg_error: - print(f"[GENEXPERT-ERROR] Mengunci/Membatalkan Order {rnoreg_error} agar tidak terjadi Infinite Loop.") - # (OPSIONAL: Jalankan fungsi update ke DB untuk mengubah flag PaslabOrder menjadi True di sini) - # ... - - # Kirim ACK agar alat berhenti mengirim error - ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") - return # BERHENTI DI SINI. Jangan lanjut ke parse_hl7_result! - # ----------------------------------------------------- - - print(f"[RESULT] Menerima Hasil Lab.") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}") - ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") - print(f"[ACK SENT] Untuk hasil ID {incoming_control_id}") - return - - if "QBP^Z01" in clean_hl7 or "QBP^Z03" in clean_hl7: - print("[GENEXPERT] Alat meminta ORDER") - msg_id = extract_msg_control_id(clean_hl7) - send_all_orders(conn, ip_addr, clean_hl7, msg_id, response_framing=response_framing) - return - - if "QCN^J01" in clean_hl7: - ack_msg = create_genexpert_ack_j01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="qcn-ack") - print(f"[GENEXPERT] Menerima konfirmasi query dari {ip_addr}.") - return - - print(f"[GenExpert_TCP] Pesan Lengkap Diterima: {clean_hl7[:50]}...") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}, ") - - try: - if len(msh_fields) > 9: - msg_control_id = msh_fields[9] - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ack_time}||ACK|{msg_control_id}|P|2.5\rMSA|AA|{msg_control_id}\r" - full_ack = f"\x0b{ack_msg}\x1c\r" - log_genexpert_hl7("OUT", ip_addr, ack_msg, label="generic-ack") - conn.sendall(full_ack.encode('utf-8')) - print(f"[ACK] Terkirim untuk ID {msg_control_id}") - except Exception as e: - print(f"Gagal kirim ACK: {e}") - -def handle_genexpert_client(conn, addr): - print(f"[GenExpert_TCP] Koneksi baru dari {addr}") - buffer = b"" - pending_astm_hl7 = None - pending_astm_framing = None - conn.settimeout(60) - client_ip = addr[0] - with connection_lock: - active_genexpert_connections[client_ip] = conn - print(f"[GenExpert_TCP] Register koneksi aktif {client_ip}") - - try: - while True: - try: - data = conn.recv(4096) - if not data: - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail="reason=connection-close") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - print(f"[GenExpert_TCP] Client {addr} menutup koneksi.") - break - - buffer += data - if b"\x02" in data: - log_genexpert_handshake(addr[0], "STX-RX", detail=f"bytes={len(data)}") - if b"\x03" in data: - log_genexpert_handshake(addr[0], "ETX-RX", detail=f"bytes={len(data)}") - if b"\x04" in data: - log_genexpert_handshake(addr[0], "EOT-RX", detail=f"bytes={len(data)}") - if b"\x06" in data: - log_genexpert_handshake(addr[0], "ACK-RX", detail=f"bytes={len(data)}") - if b"\x15" in data: - log_genexpert_handshake(addr[0], "NAK-RX", detail=f"bytes={len(data)}") - - # --- 1. HANDLE HANDSHAKE (ENQ) --- - # Jika alat kirim ENQ (\x05/♣), langsung balas ACK (\x06) - if b'\x05' in buffer: - log_genexpert_handshake(addr[0], "ENQ-RX", detail=f"buffer_len={len(buffer)}") - conn.sendall(b'\x06') - log_genexpert_handshake(addr[0], "ACK-TX", detail="reason=enq") - - # [PERBAIKAN KURSIS 2]: KOSONGKAN TOTAL BUFFER SAAT ENQ! - # Alat meminta sesi baru, pastikan tidak ada sisa pesan lama yang nyangkut - buffer = b"" - pending_astm_hl7 = "" - continue # Langsung lanjut ke recv() berikutnya - if b'\x15' in buffer: - log_genexpert_handshake(addr[0], "NAK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x15', b'') - if b'\x06' in buffer: - log_genexpert_handshake(addr[0], "ACK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x06', b'') - - # --- 2. CEK APAKAH PESAN SUDAH LENGKAP? --- - # Kita cari tanda akhir pesan umum: - # - \x1c (End Block MLLP) - # - \x03 (ETX - End Text ASTM) - # - \x04 (EOT - End Transmission ASTM) - - msg_complete = False - end_marker_pos = -1 - - if b'\x1c' in buffer: # Pola MLLP Standard - end_marker_pos = buffer.find(b'\x1c') - msg_complete = True - elif b'\x03' in buffer or b'\x17' in buffer: - # Cari di index mana letak ETX atau ETB - pos_etx = buffer.find(b'\x03') - pos_etb = buffer.find(b'\x17') - - # Tentukan mana yang muncul lebih dulu di buffer - pos = -1 - if pos_etx != -1 and pos_etb != -1: - pos = min(pos_etx, pos_etb) - else: - pos = max(pos_etx, pos_etb) - - # Pastikan kita menerima 5 bytes penuh (ETB/ETX + C1 + C2 + CR + LF) - if pos != -1 and len(buffer) >= pos + 5: - end_marker_pos = pos + 5 - msg_complete = True - - elif b'\x04' in buffer: # Pola EOT (Putus Koneksi/Selesai) - end_marker_pos = buffer.find(b'\x04') - msg_complete = True - - # --- 3. PROSES JIKA LENGKAP --- - if msg_complete: - if end_marker_pos == 0 and buffer[:1] == b'\x04': - log_genexpert_handshake(addr[0], "EOT-CLEAR", detail="standalone-eot") - buffer = buffer[1:].lstrip(b'\r').lstrip(b'\n') - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail=f"framing={pending_astm_framing}") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - continue - - # Ambil pesan dari awal sampai marker - # (Gunakan slice sampai end_marker_pos+1 agar karakter penutup ikut terambil/dibuang) - if end_marker_pos == -1: end_marker_pos = len(buffer) - - full_message_bytes = buffer[:end_marker_pos] - response_framing = detect_genexpert_message_framing(full_message_bytes) - if response_framing == "astm": - debug_genexpert_astm_frame(addr[0], full_message_bytes, direction="RX") - log_genexpert_handshake( - addr[0], - "FRAME-COMPLETE", - detail=f"framing={response_framing}, frame_len={len(full_message_bytes)}" - ) - - send_genexpert_transport_ack( - conn, - addr[0], - response_framing, - reason="incoming-frame-complete" - ) - - # Sisa buffer (jika ada paket nempel di belakangnya) disimpan untuk loop berikutnya - buffer = buffer[end_marker_pos:] - - # Jangan buang EOT di sini; jika EOT datang menempel setelah frame ASTM, - # ia harus diproses pada iterasi berikutnya agar pending ASTM message dijalankan. - buffer = buffer.lstrip(b'\r').lstrip(b'\n') - - # Decode ke string - temp_str = full_message_bytes.decode('latin-1', errors='ignore') - astm_text = extract_astm_frame_text(full_message_bytes) if response_framing == "astm" else "" - - # --- SANITIZING (PEMBERSIHAN) --- - # Cari MSH pertama - if "MSH|" in temp_str: - msh_index = temp_str.find("MSH|") - clean_hl7 = temp_str[msh_index:] - if response_framing == "astm": - pending_astm_hl7 = clean_hl7 - pending_astm_framing = response_framing - log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(clean_hl7)}") - continue - process_genexpert_hl7_message(conn, addr[0], clean_hl7, response_framing) - elif response_framing == "astm" and astm_text: - pending_astm_hl7 = (pending_astm_hl7 or "") + astm_text - pending_astm_framing = response_framing - log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(pending_astm_hl7)}, mode=records") - continue - else: - # Jika pesan lengkap tapi tidak ada MSH (misal cuma EOT doang) - pass - else: - if buffer: - head_hex = buffer[:12].hex() - log_genexpert_handshake( - addr[0], - "BUFFER-WAIT", - detail=f"buffer_len={len(buffer)}, head_hex={head_hex}" - ) - - except ConnectionResetError: - logging.warning(f"[GenExpert_TCP] Connection reset by peer: {addr}") - break - except OSError as e: - if getattr(e, "winerror", None) == 10054: - logging.warning(f"[GenExpert_TCP] WinError 10054 dari {addr}") - break - raise - except socket.timeout: - continue - except Exception as e: - print(f"[Loop Error] {e}") - logging.exception(f"[Loop Error] Unexpected error from {addr}: {e}") - break - - except Exception as e: - logging.error(f"[GenExpert_TCP Error] Koneksi {addr} terputus: {e}") - finally: - with connection_lock: - if active_genexpert_connections.get(client_ip) is conn: - del active_genexpert_connections[client_ip] - try: - conn.close() - except Exception: - pass - logging.info(f"[GenExpert_TCP] Koneksi {addr} ditutup.") - -def parse_genexpert_astm_records(astm_string, device_name): - """ - Parser khusus untuk membaca hasil ASTM dari instrumen GeneXpert - dan menyimpannya ke tabel LisPhoenix dengan aman (mencegah VARCHAR limit error). - """ - try: - records = astm_string.split('\r') - no_id = "" - rnmpas = "" - seq_no = "" - hasil_list = [] - - for rec in records: - # 1. Ambil Data Pasien (P Record) - if rec.startswith("P|"): - parts = rec.split('|') - if len(parts) > 3: - # Ambil Patient ID (Bisa di index 3 atau 4 tergantung setting alat) - no_id = parts[3].strip() or (parts[4].strip() if len(parts) > 4 else "") - if len(parts) > 5: - # Ambil Nama Pasien, ganti ^ dengan spasi - rnmpas = parts[5].replace('^', ' ').strip() - - # 2. Ambil Nomor Order / Registrasi (O Record) - elif rec.startswith("O|"): - parts = rec.split('|') - if len(parts) > 2: - seq_no = parts[2].strip() - - # 3. Ambil Hasil Tes (R Record) - elif rec.startswith("R|"): - parts = rec.split('|') - if len(parts) > 3: - test_info = parts[2] # Contoh: ^^^MTB-RIF_ULTRA 2^^^MTB^ - result_val = parts[3].replace('^', '').strip() # Contoh: DETECTED atau INVALID - - # [KUNCI]: Abaikan kurva analitik agar teks tidak kepanjangan - if "Ct|" not in rec and "EndPt|" not in rec and result_val: - # Ekstrak nama targetnya saja (misal "MTB" atau "RIF Resistance") - target_match = test_info.split('^^^') - target_name = target_match[-1].strip('^') if len(target_match) > 1 else "" - - if target_name and result_val: - hasil_list.append(f"{target_name}: {result_val}") - - # Gabungkan hasil-hasil penting menjadi 1 string - kesimpulan = " | ".join(hasil_list) - - # [PATCH KRITIS]: Potong string agar TIDAK CRASH di database - no_id_safe = no_id[:50] - seq_no_safe = seq_no[:50] - rnmpas_safe = rnmpas[:100] - kesimpulan_safe = kesimpulan[:100] # Membatasi maksimal 100 karakter untuk kolom 'organisme' - - if not seq_no_safe: - print("[GENEXPERT-PARSER] Warning: seq_no (No Order) tidak ditemukan dalam pesan hasil.") - return - - # Simpan ke Database - with SessionLocal() as session: - new_result = LisPhoenix( - no_id=no_id_safe, - seq_no=seq_no_safe, - rnmpas=rnmpas_safe, - tgl_data=datetime.datetime.now().date(), - rawdt=astm_string, # Simpan data mentahnya (utuh) ke TEXT untuk jaga-jaga/bisa dibaca ulang - organisme=kesimpulan_safe, # Hasil yang sudah bersih dan muat - alat=device_name - ) - session.add(new_result) - source_ip = str(device_name or "").replace("GeneXpert-", "").strip() - session.commit() - mark_genexpert_order_flag(seq_no_safe, source_ip, reason="astm-result-received") - print(f"[GENEXPERT-DB-SUCCESS] Hasil lab untuk Order {seq_no_safe} berhasil disimpan ke LisPhoenix!") - - except Exception as e: - print(f"[GENEXPERT-PARSER-ERROR] Gagal memparsing/menyimpan hasil: {e}") - traceback.print_exc() - -def parse_genexpert_qpd(qpd_segment): - fields = str(qpd_segment or "").split('|') - query_name = fields[1] if len(fields) > 1 else "" - query_tag = fields[2] if len(fields) > 2 else "" - param_1 = fields[3] if len(fields) > 3 else "" - param_2 = fields[4] if len(fields) > 4 else "" - return { - "query_name": query_name, - "query_tag": query_tag, - "param_1": param_1, - "param_2": param_2, - } - -def resolve_genexpert_assay(order, ip_addr=None): - assay_name = str(getattr(order, "tes", "") or "").strip() - specimen_code = str(getattr(order, "kd_spesimen", "") or "").strip() - - assay_code = GENEXPERT_TEST_MAPPING.get(assay_name) - assay_source = "mapping:tes" - - if not assay_code: - assay_code = DEFAULT_GXP_CODE - assay_source = "default" - - supported_codes = ( - GENEXPERT_IP_CAPABILITIES.get(str(ip_addr or "").strip(), []) - if ip_addr else [] - ) - - capability_match = ( - True if not supported_codes - else assay_code in supported_codes - ) - - print( - f"[GENEXPERT-DEBUG] " - f"rnoreg={getattr(order, 'rnoreg', '')}, " - f"ip={ip_addr}, " - f"tes='{assay_name}', " - f"kd_spesimen='{specimen_code}', " - f"assay_code='{assay_code}', " - f"assay_source={assay_source}, " - f"capability_match={capability_match}" - ) - - return assay_code, assay_source, capability_match - -def get_genexpert_query_orders(ip_addr, hl7_msg): - flag = get_flag_by_device(ip_addr) - if not flag: - return [] - - qpd_segment = extract_segment(hl7_msg, "QPD") - qpd = parse_genexpert_qpd(qpd_segment) - param_1 = str(qpd.get("param_1") or "").strip() - param_2 = str(qpd.get("param_2") or "").strip() - - session = SessionLocal() - try: - flag_attr = getattr(PaslabOrder, flag, None) - if flag_attr is None: - return [] - - base_orders = session.query(PaslabOrder).filter( - (flag_attr == False) | (flag_attr == None) - ).order_by(PaslabOrder.rtglast.desc().nullslast(), PaslabOrder.urut.desc()).all() - - requested_sample_id = (param_2 or param_1).strip() if (param_2 or param_1) else "" - if requested_sample_id and requested_sample_id.upper() != "ALL": - for order in base_orders: - if str(order.rnoreg or "").strip() != requested_sample_id: - continue - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - return [] - return [order] - print(f"[GENEXPERT] Tidak ada order untuk sample_id={requested_sample_id} di {ip_addr}") - return [] - - for order in base_orders: - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if assay_code: - return [order] - print(f"[GENEXPERT] Tidak ada order dengan assay valid untuk IP {ip_addr}") - return [] - finally: - session.close() - -def build_genexpert_response_msh(message_code, incoming_hl7, resp_control_id, ip_addr=None): - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - msh_fields = extract_segment(incoming_hl7, "MSH").split('|') - sender_app = msh_fields[2] if len(msh_fields) > 2 else "GeneXpert" - sender_fac = msh_fields[3] if len(msh_fields) > 3 else "" - host_app = get_genexpert_host_application(ip_addr) - return f"MSH|^~\\&|{host_app}||{sender_app}|{sender_fac}|{timestamp}||{message_code}|{resp_control_id}|P|2.5|||NE|NE" - -def create_genexpert_ack_j01_response(incoming_hl7, ip_addr=None): - incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" - resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - msh = build_genexpert_response_msh("ACK^J01", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|CA|{incoming_control_id}" - return f"{msh}\r{msa}\r" - -def create_genexpert_ack_r01_response(incoming_hl7, ip_addr=None): - incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" - resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - msh = build_genexpert_response_msh("ACK^R01", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|CA|{incoming_control_id}" - return f"{msh}\r{msa}\r" - -def create_genexpert_rsp_z02_response(orders, incoming_hl7, ip_addr=None): - qpd_segment = extract_segment(incoming_hl7, "QPD") - qpd = parse_genexpert_qpd(qpd_segment) - query_tag = qpd.get("query_tag") or (extract_msg_control_id(incoming_hl7) or "UNKNOWN") - incoming_message_type = extract_message_type(incoming_hl7) - query_name = qpd.get("query_name") or "Z03^HOST QUERY" - resp_control_id = f"RSP{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - - if str(incoming_message_type or "").startswith("QBP^Z03"): - query_name = "Z03^HOST QUERY" - - msh = build_genexpert_response_msh("RSP^Z02", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|AA|{query_tag}" - qak = f"QAK|{query_tag}|OK|{query_name}" - segments = [msh, msa, qak] - if str(incoming_message_type or "").startswith("QBP^Z03"): - selected_patient_id = "" - selected_sample_id = "" - if orders: - selected_patient_id = sanitize_astm_field(orders[0].norm or "", max_len=50) - selected_sample_id = sanitize_astm_field(orders[0].rnoreg or "", max_len=50) - segments.append(f"QPD|{query_name}|{query_tag}|{selected_patient_id}|{selected_sample_id}") - elif qpd_segment: - segments.append(qpd_segment) - - for patient_idx, order in enumerate(orders, start=1): - patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=50) - sample_id = sanitize_astm_field(order.rnoreg, max_len=50) - order_ts = ( - getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') - if getattr(order, "rtglast", None) - else datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ) - assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - print(f"[GENEXPERT] Payload order dilewati rnoreg={sample_id} karena assay kosong.") - continue - - print( - f"[GENEXPERT-DEBUG] Build RSP rnoreg={sample_id}, ip={ip_addr}, " - f"patient_id={patient_id}, assay_code={assay_code}, assay_source={assay_source}, " - f"capability_match={capability_match}, query_name='{query_name}', query_tag='{query_tag}', " - "profile='minimal-rsp-z02'" - ) - - segments.append(f"PID|{patient_idx}||{patient_id}") - segments.append(f"ORC|NW|1|||||||{order_ts}") - segments.append(f"OBR|1|||{assay_code}|||||||A") - segments.append("TQ1|||||||||R") - segments.append(f"SPM|1|{sample_id}^||ORH|||||||P") - - return "\r".join(segments) + "\r" - -def send_all_orders(conn, ip_addr, hl7_msg, msg_id, response_framing="mllp"): - orders = get_genexpert_query_orders(ip_addr, hl7_msg) - if not orders: - print(f"[GENEXPERT] Tidak ada order pending untuk {ip_addr}") - rsp = create_genexpert_rsp_z02_response([], hl7_msg, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, rsp, response_framing, label="qbp-empty") - return - - qpd_segment = extract_segment(hl7_msg, "QPD") - print( - f"[GENEXPERT-DEBUG] QBP diproses untuk ip={ip_addr}, msg_id={msg_id}, " - f"qpd='{qpd_segment}', selected_rnoreg={[str(order.rnoreg or '').strip() for order in orders]}" - ) - print(f"[GENEXPERT] Mengirim {len(orders)} order ke {ip_addr}") - rsp = create_genexpert_rsp_z02_response(orders, hl7_msg, ip_addr=ip_addr) - first_accnumber = str(orders[0].rnoreg or "").strip() if orders else "" - debug_genexpert_order_message(rsp, ip_addr=ip_addr) - sent_ok = send_genexpert_response(conn, ip_addr, rsp, response_framing, label=f"qbp-order:{first_accnumber}") - - for order in orders: - rnoreg = str(order.rnoreg or "").strip() - print(f"[GENEXPERT] Order ditawarkan ke {ip_addr}: {rnoreg}, sent_ok={sent_ok}") - if sent_ok: - mark_genexpert_order_flag(rnoreg, ip_addr, reason="hl7-order-sent") - -def log_genexpert_hl7(direction, ip_addr, hl7_message, label=""): - message_type = extract_message_type(hl7_message) or "UNKNOWN" - control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" - suffix = f", label={label}" if label else "" - preview = build_hl7_preview(hl7_message) - logging.info( - f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" - ) - print( - f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" - ) - -def log_genexpert_hl7_full(direction, ip_addr, hl7_message, label=""): - message_type = extract_message_type(hl7_message) or "UNKNOWN" - control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" - suffix = f", label={label}" if label else "" - payload = str(hl7_message or "").replace("\r", "\\r\n") - logging.info( - f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" - ) - print( - f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" - ) - -def detect_genexpert_message_framing(message_bytes): - raw = message_bytes or b"" - if b"\x1c" in raw or raw.startswith(b"\x0b"): - return "mllp" - if b"\x03" in raw or b"\x17" in raw: - return "astm" - return "plain" - -def extract_astm_frame_text(frame_bytes): - raw = frame_bytes or b"" - if not raw.startswith(b"\x02"): - return "" - - etx_pos = raw.find(b"\x03") - etb_pos = raw.find(b"\x17") - if etx_pos != -1 and etb_pos != -1: - end_pos = min(etx_pos, etb_pos) - else: - end_pos = max(etx_pos, etb_pos) - - if end_pos == -1 or end_pos < 2: - return "" - - return raw[2:end_pos].decode("latin-1", errors="ignore") - -def frame_genexpert_response(hl7_message, framing): - message = str(hl7_message or "") - if framing == "astm": - frame_body = f"1{message}\x03" - chk = calculate_astm_checksum(frame_body) - return f"\x02{frame_body}{chk}\r\n".encode("latin-1") - if framing == "mllp": - return f"\x0b{message}\x1c\r".encode("utf-8") - return message.encode("utf-8") - -def build_genexpert_astm_frames(hl7_message, max_text_bytes=240): - message = str(hl7_message or "") - text_bytes = message.encode("latin-1") - chunks = [text_bytes[i:i + max_text_bytes] for i in range(0, len(text_bytes), max_text_bytes)] or [b""] - frames = [] - frame_number = 1 - - for index, chunk in enumerate(chunks): - is_last = index == len(chunks) - 1 - terminator = b"\x03" if is_last else b"\x17" - frame_no_byte = str(frame_number).encode("ascii") - frame_core_bytes = frame_no_byte + chunk + terminator - checksum = calculate_astm_checksum(frame_core_bytes.decode("latin-1", errors="ignore")).encode("ascii") - full_frame = b"\x02" + frame_core_bytes + checksum + b"\x0D\x0A" - frames.append({ - "frame_number": frame_number, - "is_last": is_last, - "chunk_len": len(chunk), - "payload": full_frame, - "checksum": checksum.decode("ascii", errors="ignore"), - }) - frame_number = (frame_number + 1) % 8 - return frames - -def recv_genexpert_control_char(conn, timeout_seconds=5): - previous_timeout = conn.gettimeout() - try: - conn.settimeout(timeout_seconds) - return conn.recv(1) - finally: - conn.settimeout(previous_timeout) - -def send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=""): - try: - frames = build_genexpert_astm_frames(hl7_message) - print(f"[GENEXPERT-ASTM-TX] ip={ip_addr}, label={label}, total_frames={len(frames)}, mode=astm_active") - conn.sendall(b"\x05") - log_genexpert_handshake(ip_addr, "ENQ-TX", detail=f"label={label}") - - ctrl = recv_genexpert_control_char(conn, timeout_seconds=5) - if ctrl == b"\x06": - log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=pre-frame,label={label}") - elif ctrl == b"\x15": - log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=pre-frame,label={label}") - return False - else: - log_genexpert_handshake(ip_addr, "CTRL-RX", detail=f"phase=pre-frame,label={label},hex={ctrl.hex() if ctrl else 'timeout'}") - return False - - for frame in frames: - payload = frame["payload"] - debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=f"{label}:frame{frame['frame_number']}") - conn.sendall(payload) - log_genexpert_handshake( - ip_addr, - "FRAME-TX", - detail=( - f"label={label},frame_no={frame['frame_number']},bytes={len(payload)}," - f"chunk_len={frame['chunk_len']},last={frame['is_last']}" - ), - ) - - ctrl = recv_genexpert_control_char(conn, timeout_seconds=15) - if ctrl == b"\x06": - log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - continue - if ctrl == b"\x15": - log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=nak,frame_no={frame['frame_number']}") - return False - if ctrl == b"\x04": - log_genexpert_handshake(ip_addr, "EOT-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after-peer-eot,frame_no={frame['frame_number']}") - return False - - log_genexpert_handshake( - ip_addr, - "CTRL-RX", - detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']},hex={ctrl.hex() if ctrl else 'timeout'}", - ) - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=unexpected,frame_no={frame['frame_number']}") - return False - - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label}") - return True - except Exception as exc: - log_genexpert_handshake(ip_addr, "ASTM-SEND-ERROR", detail=f"label={label},error={exc}") - return False - -def get_genexpert_response_mode(ip_addr): - ip_addr = str(ip_addr or "").strip() - mode = GENEXPERT_RESPONSE_MODE_BY_IP.get(ip_addr, GENEXPERT_RESPONSE_MODE_DEFAULT) - if mode not in {"hl7_passive", "astm_active"}: - mode = GENEXPERT_RESPONSE_MODE_DEFAULT - return mode - -def send_genexpert_response(conn, ip_addr, hl7_message, framing, label=""): - log_genexpert_hl7("OUT", ip_addr, hl7_message, label=label) - log_genexpert_hl7_full("OUT", ip_addr, hl7_message, label=label) - response_mode = get_genexpert_response_mode(ip_addr) - if framing == "astm" and response_mode == "astm_active": - print( - f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " - f"response_mode={response_mode}, label={label}, bytes=multi-frame" - ) - return send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=label) - - payload = frame_genexpert_response(hl7_message, framing) - if framing == "astm": - debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=label) - else: - print( - f"[GENEXPERT-FRAME-TX] ip={ip_addr}, label={label}, framing={framing}, " - f"hex={_hex_bytes(payload)}, visible={_visible_bytes(payload)}" - ) - print( - f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " - f"response_mode={response_mode}, label={label}, bytes={len(payload)}" - ) - try: - conn.sendall(payload) - return True - except Exception as exc: - print(f"[GENEXPERT-DEBUG] Send response gagal ip={ip_addr}, label={label}, error={exc}") - return False - -def send_genexpert_transport_ack(conn, ip_addr, framing, reason="frame-received"): - if framing != "astm": - return - try: - conn.sendall(b"\x06") - print(f"[GENEXPERT-DEBUG] Transport ACK sent ip={ip_addr}, framing={framing}, reason={reason}") - except Exception as exc: - print(f"[GENEXPERT-DEBUG] Transport ACK gagal ip={ip_addr}, framing={framing}, reason={reason}, error={exc}") - -def log_genexpert_handshake(ip_addr, event, detail=""): - suffix = f", detail={detail}" if detail else "" - print(f"[GENEXPERT-HANDSHAKE] ip={ip_addr}, event={event}{suffix}") - -def debug_genexpert_astm_frame(ip_addr, frame_bytes, direction="RX", label=""): - raw = frame_bytes or b"" - suffix = f", label={label}" if label else "" - if not raw: - print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, empty-frame") - return - - detail_parts = [ - f"len={len(raw)}", - f"hex={_hex_bytes(raw)}", - f"visible={_visible_bytes(raw)}", - ] - - if raw.startswith(b"\x02") and len(raw) >= 6: - frame_no = raw[1:2] - etx_pos = raw.find(b"\x03") - etb_pos = raw.find(b"\x17") - end_pos = etx_pos if etx_pos != -1 else etb_pos - end_name = "ETX" if etx_pos != -1 else ("ETB" if etb_pos != -1 else "NONE") - if end_pos != -1 and len(raw) >= end_pos + 5: - checksum_rx = raw[end_pos + 1:end_pos + 3] - trailer = raw[end_pos + 3:end_pos + 5] - checksum_basis = raw[1:end_pos + 1].decode("latin-1", errors="ignore") - checksum_calc = calculate_astm_checksum(checksum_basis).encode("ascii") - checksum_ok = checksum_rx.upper() == checksum_calc.upper() - trailer_ok = trailer == b"\r\n" - detail_parts.extend([ - f"frame_no={frame_no.decode('ascii', errors='ignore')}", - f"terminator=<{end_name}>", - f"checksum_rx={checksum_rx.decode('ascii', errors='ignore')}", - f"checksum_calc={checksum_calc.decode('ascii', errors='ignore')}", - f"checksum_ok={checksum_ok}", - f"trailer={_visible_bytes(trailer)}", - f"trailer_ok={trailer_ok}", - ]) - else: - detail_parts.append(f"terminator=<{end_name}>") - - print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, " + ", ".join(detail_parts)) - -def debug_genexpert_order_message(hl7_message, ip_addr=None): - segments = parse_hl7_segments(hl7_message) - current_pid = "" - current_patient_name = "" - order_index = 0 - - for segment in segments: - fields = segment.split("|") - seg_type = fields[0] if fields else "" - - if seg_type == "PID": - current_pid = fields[3] if len(fields) > 3 else "" - current_patient_name = fields[5] if len(fields) > 5 else "" - dob = fields[7] if len(fields) > 7 else "" - sex = fields[8] if len(fields) > 8 else "" - address = fields[11] if len(fields) > 11 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=PID, patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', dob='{dob}', sex='{sex}', " - f"address='{address}', mode='minimal', raw='{segment}'" - ) - elif seg_type == "ORC": - order_index = fields[2] if len(fields) > 2 else "" - order_time = fields[9] if len(fields) > 9 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=ORC, placer_order='{order_index}', " - f"order_time='{order_time}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "OBR": - assay_code = fields[4] if len(fields) > 4 else "" - result_status = fields[11] if len(fields) > 11 else "" - assay_parts = assay_code.split("^") - assay_id = assay_parts[0] if len(assay_parts) > 0 else "" - assay_name = assay_parts[1] if len(assay_parts) > 1 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=OBR, placer_order='{order_index}', " - f"assay_code='{assay_id}', assay_name='{assay_name}', result_status='{result_status}', " - f"patient_id='{current_pid}', patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "TQ1": - priority = fields[9] if len(fields) > 9 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=TQ1, placer_order='{order_index}', " - f"priority='{priority}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "SPM": - specimen_id = fields[2] if len(fields) > 2 else "" - specimen_type = fields[4] if len(fields) > 4 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=SPM, placer_order='{order_index}', " - f"specimen_id='{specimen_id}', specimen_type='{specimen_type}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - -# ========================================== -# bioMérieux MYLA TCP Server Handler -# ========================================== -def parse_myla_result(hl7_message, device_name="MYLA"): - """ - Parser khusus untuk HL7 dari bioMérieux MYLA (Kalibrasi V4.9). - Menangani hasil BACT/ALERT (Kultur Darah) dan VITEK (Identifikasi & AST). - """ - session = SessionLocal() - try: - segments = hl7_message.strip().split('\r') - - sample_id = None - patient_id = "" - patient_name = "" - result_date = datetime.datetime.now() - - kultur_id = [] - ast_results = [] - - for segment in segments: - fields = segment.split('|') - if not fields: continue - seg_type = fields[0] - - if seg_type == 'MSH': - if len(fields) > 6 and fields[6]: - try: - result_date = datetime.datetime.strptime(fields[6][:14], "%Y%m%d%H%M%S") - except: pass - - elif seg_type == 'PID': - if len(fields) > 3: patient_id = fields[3].replace('^', '') - if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() - - elif seg_type == 'OBR': - if len(fields) > 3 and fields[3]: - sample_id = fields[3].replace('^', '') - elif len(fields) > 2 and fields[2]: - sample_id = fields[2].replace('^', '') - - elif seg_type == 'OBX': - if len(fields) > 5: - test_param = fields[3].split('^')[1] if '^' in fields[3] else fields[3] - - if "Time To Detection" in test_param: - continue - - raw_val = fields[5] - val_parts = raw_val.split('^') - - if len(val_parts) > 1: - if val_parts[0] in ['<', '<=', '>', '>=', '=']: - result_val = f"{val_parts[0]} {val_parts[1]}" - else: - result_val = val_parts[1] - else: - result_val = raw_val.strip() - - interpretation = "" - if len(fields) > 8 and fields[8]: - interpretation = fields[8].split('^')[0].strip().upper() - - if interpretation in ['S', 'I', 'R', 'NS']: - ast_results.append(f"{test_param}: {result_val} ({interpretation})") - else: - kultur_id.append(f"{test_param}: {result_val}") - - final_results = [] - if kultur_id: - final_results.append("ID: " + ", ".join(kultur_id)) - if ast_results: - final_results.append("AST: " + "; ".join(ast_results)) - - final_result_str = " | ".join(final_results) if final_results else "No Data" - - if not sample_id: - sample_id = f"ERR_MYLA_{datetime.datetime.now().strftime('%H%M%S')}" - final_result_str = f"[NO_ID] {final_result_str}" - - - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=hl7_message, - organisme=final_result_str[:255], - alat=device_name - ) - session.add(new_entry) - session.commit() - - except Exception as e: - logging.error(f"[MYLA Parser] Error: {e}") - session.rollback() - finally: - session.close() - -def get_pending_myla_orders(limit=10): - session = SessionLocal() - try: - return session.query(PaslabOrder).filter( - (PaslabOrder.flg_vitek3 == False) | (PaslabOrder.flg_vitek3 == None) - ).order_by(PaslabOrder.urut.asc()).limit(limit).all() - finally: - session.close() - -def mark_myla_order_sent(order_id): - session = SessionLocal() - try: - order = session.query(PaslabOrder).filter(PaslabOrder.urut == order_id).first() - if order: - order.flg_vitek3 = True - session.commit() - return True - return False - except Exception: - session.rollback() - raise - finally: - session.close() - -def parse_myla_astm_records(raw_message, device_name="MYLA"): - session = SessionLocal() - try: - sample_id = "" - patient_id = "" - patient_name = "" - results = [] - - records = [r for r in raw_message.split('\r') if r.strip()] - for rec in records: - row = rec[1:] if rec and rec[0].isdigit() else rec - fields = row.split('|') - if not fields: - continue - - rtype = fields[0] - if rtype == "P": - if len(fields) > 3: - patient_id = fields[3].strip() - if len(fields) > 5: - patient_name = fields[5].replace("^", " ").strip() - elif rtype == "O": - if len(fields) > 2: - sample_id = fields[2].replace("^", "").strip() - elif rtype == "R": - test_name = fields[2].strip() if len(fields) > 2 else "" - test_value = fields[3].strip() if len(fields) > 3 else "" - if test_name or test_value: - results.append(f"{test_name}: {test_value}".strip(": ")) - - if sample_id and results: - final_result_str = " | ".join(results)[:255] - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=datetime.datetime.now(), - rawdt=raw_message, - organisme=final_result_str, - alat=device_name - ) - session.add(new_entry) - session.commit() - else: - print("[MYLA-ASTM] ASTM message diterima (tanpa hasil R untuk disimpan).") - except Exception as e: - logging.error(f"[MYLA-ASTM] Error parse/simpan: {e}") - session.rollback() - finally: - session.close() - -def _read_until_lf(conn, timeout_seconds=5): - deadline = time.time() + timeout_seconds - payload = b"" - while time.time() < deadline: - conn.settimeout(max(0.1, deadline - time.time())) - chunk = conn.recv(1) - if not chunk: - break - payload += chunk - if payload.endswith(b"\n"): - break - return payload - -def receive_myla_astm_transmission(conn, peer_ip, first_control=ENQ): - """ - Menerima transmisi ASTM dari server: - ENQ -> ACK, lalu STX frame(s) -> ACK/NAK tiap frame, diakhiri EOT. - """ - if first_control == ENQ: - conn.sendall(ACK) - - assembled = [] - pending_ctrl = first_control if first_control == STX else None - while True: - if pending_ctrl is not None: - ctrl = pending_ctrl - pending_ctrl = None - else: - conn.settimeout(5) - ctrl = conn.recv(1) - if not ctrl: - return - if ctrl == EOT: - break - if ctrl == ENQ: - conn.sendall(ACK) - continue - if ctrl != STX: - continue - - payload = _read_until_lf(conn, timeout_seconds=5) - if not payload: - conn.sendall(NAK) - continue - - frame = ctrl + payload - try: - frame_text = frame.decode("latin-1", errors="ignore") - body_start = frame_text.find("\x02") - etx_pos = frame_text.find("\x03") - if body_start == -1 or etx_pos == -1 or etx_pos <= body_start: - conn.sendall(NAK) - continue - - frame_body = frame_text[body_start + 1:etx_pos + 1] - recv_chk = frame_text[etx_pos + 1:etx_pos + 3].upper() - calc_chk = calculate_astm_checksum(frame_body).upper() - if recv_chk != calc_chk: - logging.warning(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") - print(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") - conn.sendall(NAK) - continue - - # Drop seq(1 char) dan ETX - data_part = frame_body[1:-1] - assembled.append(data_part) - conn.sendall(ACK) - except Exception as e: - logging.error(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") - print(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") - conn.sendall(NAK) - - if assembled: - raw_message = "".join(assembled) - parse_myla_astm_records(raw_message, device_name=f"MYLA-{peer_ip}") - -def create_myla_hl7_order_message(order, msg_control_id): - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - order_timestamp = format_hl7_datetime(getattr(order, "rtglast", None)) or timestamp - patient_birth_date = format_hl7_date(getattr(order, "tgllahir", None)) - patient_admit_date = format_hl7_date(getattr(order, "rtglast", None)) - sample_id = sanitize_astm_field(order.rnoreg, uppercase=True, max_len=32) or "000000" - pid_norm = sanitize_astm_field(order.norm, uppercase=True, max_len=32) or f"PID{sample_id}" - first_name, last_name = split_patient_name(order.nama) - patient_name = f"{last_name}^{first_name}".strip("^") - room = sanitize_astm_field(getattr(order, "ruangan", "") or "RSSA", uppercase=True, max_len=30) - test_code_raw = sanitize_astm_field(order.tes, uppercase=True, max_len=40) - test_code = test_code_raw if test_code_raw in {"BC", "ID", "SU"} else "ID" - specimen_code = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) or "KULTUR URINE" - - # Sesuaikan pattern ID seperti contoh vendor (SPE/AWOS + accession). - compact_id = re.sub(r"[^A-Z0-9]", "", sample_id) or "000000" - #spm_id = f"SPE{compact_id}"[:30] - spm_id = sample_id - obr_order_id = f"AWOS{compact_id}"[:30] - - msh = ( - f"MSH|^~\\&|LIS|LAB|MYLA|BMX|{timestamp}||OML^O33^OML_O33|{msg_control_id}|P|2.5.1" - f"|||NE|AL||UNICODE UTF-8" - ) - pid = build_hl7_segment("PID", { - 3: pid_norm, - 5: patient_name, - 7: patient_birth_date, - }) - pv1 = build_hl7_segment("PV1", { - 2: "O", - 3: f"{room}^^^RSSA", - 44: patient_admit_date, - }) - spm = build_hl7_segment("SPM", { - 1: "1", - 2: spm_id, - 4: f"{specimen_code}^{specimen_code}^99BMx", - 11: "P^Patient^HL70369", - 17: order_timestamp, - }) - orc = build_hl7_segment("ORC", { - 1: "NW", - 9: order_timestamp, - }) - tq1 = build_hl7_segment("TQ1", { - 9: "R^Routine^HL70485", - }) - obr = build_hl7_segment("OBR", { - 1: "1", - 2: obr_order_id, - 4: f"{test_code}^{test_code}^99BMx", - }) - return f"{msh}\r{pid}\r{pv1}\r{spm}\r{orc}\r{tq1}\r{obr}\r" - -def process_myla_hl7_message(conn, hl7_str, peer_ip): - incoming_control_id = "" - message_type = "" - ack_code = "" - ack_for_control_id = "" - err_text = "" - orc_control = "" - orc_status = "" - orc_ref = "" - - try: - msh_fields = hl7_str.split('\r')[0].split('|') - if len(msh_fields) > 8: - message_type = msh_fields[8].strip().upper() - if len(msh_fields) > 9: - incoming_control_id = msh_fields[9].strip() - except Exception: - pass - - if "ORU^" in message_type or message_type.startswith("OUL^R22"): - print(f"[MYLA-HL7] Menerima hasil dari {peer_ip} (type={message_type}, ID: {incoming_control_id})") - parse_myla_result(hl7_str, device_name=f"MYLA-{peer_ip}") - if incoming_control_id: - try: - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = ( - f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\r" - f"MSA|AA|{incoming_control_id}\r" - ) - conn.sendall(f"\x0b{ack_msg}\x1c\r".encode("utf-8")) - except Exception as e: - logging.warning(f"[MYLA-HL7] Gagal kirim ACK hasil {incoming_control_id}: {e}") - elif message_type.startswith("ACK") or message_type.startswith("ORL^O34"): - for seg in hl7_str.split('\r'): - if seg.startswith("MSA|"): - parts = seg.split('|') - if len(parts) > 1: - ack_code = parts[1].strip().upper() - if len(parts) > 2: - ack_for_control_id = parts[2].strip() - break - for seg in hl7_str.split('\r'): - if seg.startswith("ERR|"): - err_parts = seg.split('|') - if len(err_parts) > 2: - err_text = err_parts[2].strip() - if len(err_parts) > 3 and err_parts[3]: - err_text = f"{err_text} {err_parts[3].strip()}".strip() - break - for seg in hl7_str.split('\r'): - if seg.startswith("ORC|"): - o = seg.split('|') - if len(o) > 1: - orc_control = o[1].strip().upper() - if len(o) > 5: - orc_status = o[5].strip().upper() - if len(o) > 6: - orc_ref = o[6].strip() - break - - return { - "message_type": message_type, - "control_id": incoming_control_id, - "ack_code": ack_code, - "ack_for_control_id": ack_for_control_id, - "err_text": err_text, - "orc_control": orc_control, - "orc_status": orc_status, - "orc_ref": orc_ref, - } - -def wait_for_myla_hl7_ack(conn, expected_control_id, peer_ip, timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS): - deadline = time.time() + timeout_seconds - buffer = b"" - - while time.time() < deadline: - try: - conn.settimeout(max(0.1, deadline - time.time())) - data = conn.recv(4096) - if not data: - return False - buffer += data - - if b"\x1c\r" not in buffer: - continue - - chunks = buffer.split(b"\x1c\r") - for raw_msg in chunks[:-1]: - clean_msg = raw_msg.replace(b"\x0b", b"") - hl7_str = clean_msg.decode("latin-1", errors="ignore") - if "MSH|" not in hl7_str: - continue - - hl7_str = hl7_str[hl7_str.find("MSH|"):] - parsed = process_myla_hl7_message(conn, hl7_str, peer_ip) - if parsed["message_type"].startswith("ACK") or parsed["message_type"].startswith("ORL^O34"): - ack_for = parsed.get("ack_for_control_id", "") - ack_code = parsed.get("ack_code", "") - err_text = parsed.get("err_text", "") - orc_control = parsed.get("orc_control", "") - orc_status = parsed.get("orc_status", "") - orc_ref = parsed.get("orc_ref", "") - if ack_for == expected_control_id: - logging.info( - f"[MYLA-HL7] Response diterima untuk {expected_control_id} " - f"(type={parsed.get('message_type')}, code={ack_code or '-'}, " - f"orc1={orc_control or '-'}, orc5={orc_status or '-'}, orc6={orc_ref or '-'}, " - f"err={err_text or '-'})" - ) - app_ok = True - if parsed["message_type"].startswith("ORL^O34"): - app_ok = (orc_control == "OK") - return (ack_code in ("AA", "CA")) and app_ok - logging.info( - f"[MYLA-HL7] Response bukan untuk pesan ini " - f"(expected={expected_control_id}, msa2={ack_for or '-'}, " - f"type={parsed.get('message_type')}, code={ack_code or '-'})" - ) - - buffer = chunks[-1] - except socket.timeout: - continue - except Exception as e: - logging.error(f"[MYLA-HL7] Error wait ACK: {e}") - print(f"[MYLA-HL7] Error wait ACK: {e}") - return False - - return False - -def pump_myla_incoming(conn, peer_ip, buffer, wait_seconds=0.2): - """ - Proses pesan masuk dari MyLA saat idle (mis. ORU hasil) pada koneksi client yang sama. - """ - deadline = time.time() + max(0.05, wait_seconds) - while time.time() < deadline: - try: - conn.settimeout(max(0.05, deadline - time.time())) - data = conn.recv(4096) - if not data: - raise ConnectionError("Koneksi ditutup oleh MyLA") - buffer += data - - if b"\x1c\r" not in buffer: - continue - - chunks = buffer.split(b"\x1c\r") - for raw_msg in chunks[:-1]: - clean_msg = raw_msg.replace(b"\x0b", b"") - hl7_str = clean_msg.decode("latin-1", errors="ignore") - if "MSH|" not in hl7_str: - continue - hl7_str = hl7_str[hl7_str.find("MSH|"):] - process_myla_hl7_message(conn, hl7_str, peer_ip) - - buffer = chunks[-1] - except socket.timeout: - break - - return buffer - -def send_order_to_myla_hl7(conn, order, peer_ip): - msg_control_id = f"MYLA{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}{order.urut}" - hl7_message = create_myla_hl7_order_message(order, msg_control_id) - mllp_payload = f"\x0b{hl7_message}\x1c\r".encode("utf-8") - msh_line = hl7_message.split('\r')[0] - print(f"[MYLA-HL7] Kirim rnoreg={order.rnoreg}, MSH={msh_line}") - - conn.sendall(mllp_payload) - ack_ok = wait_for_myla_hl7_ack( - conn, - expected_control_id=msg_control_id, - peer_ip=peer_ip, - timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS - ) - return ack_ok - -def handle_myla_client(conn, addr): - """ - TCP Handler untuk bioMérieux MYLA. - Hanya menerima HL7 berbungkus MLLP (\x0b ... \x1c\r). - """ - print(f"[MYLA-TCP] Koneksi baru dari {addr}") - buffer = b"" - - try: - while True: - data = conn.recv(4096) - if not data: break - - buffer += data - - # Cek penutup MLLP (\x1c\r) - if b'\x1c\r' in buffer: - # Pisahkan pesan jika ada beberapa pesan yang nempel - messages = buffer.split(b'\x1c\r') - - for msg in messages[:-1]: # Abaikan yang terakhir (karena string kosong atau pesan belum selesai) - # Hapus pembuka MLLP (\x0b) - clean_msg_bytes = msg.replace(b'\x0b', b'') - hl7_str = clean_msg_bytes.decode('latin-1', errors='ignore') - - if "MSH|" in hl7_str: - # Potong tepat dari MSH - hl7_str = hl7_str[hl7_str.find("MSH|"):] - - # Ambil Control ID untuk ACK - incoming_control_id = "" - try: - msh_fields = hl7_str.split('\r')[0].split('|') - if len(msh_fields) > 9: - incoming_control_id = msh_fields[9] - except: pass - - # --- PROSES HASIL (ORU / OUL^R22) --- - if "ORU^" in hl7_str or "OUL^R22" in hl7_str: - print(f"[MYLA] Menerima hasil (ORU/OUL) ID: {incoming_control_id}") - parse_myla_result(hl7_str, device_name=f"MYLA-{addr[0]}") - - # --- PROSES QUERY (QRY/QBP) - JIKA MYLA BERTANYA ORDER --- - elif "QRY^" in hl7_str or "QBP^" in hl7_str: - client_ip = addr[0] - print(f"[MYLA] Menerima Query dari {client_ip} (Control ID: {incoming_control_id})") - logging.info(f"[MYLA] Permintaan Order Tidak di Proses (Control ID: {incoming_control_id})") - - - # --- KIRIM ACK --- - if incoming_control_id: - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\rMSA|AA|{incoming_control_id}\r" - full_ack = f"\x0b{ack_msg}\x1c\r" - conn.sendall(full_ack.encode('utf-8')) - print(f"[MYLA ACK] Terkirim untuk ID {incoming_control_id}") - - # Sisakan bagian terakhir di buffer (kalau ada pesan yang terpotong) - buffer = messages[-1] - - except Exception as e: - logging.error(f"[MYLA-TCP] Error koneksi {addr}: {e}") - print(f"[MYLA-TCP] Error koneksi {addr}: {e}") - finally: - conn.close() - logging.info(f"[MYLA-TCP] Koneksi {addr} ditutup.") - print(f"[MYLA-TCP] Koneksi {addr} ditutup.") - -def start_myla_server(host, port): - """ - Listener berperan sebagai TCP client: - - Konek ke MYLA server - - Poll order yang belum terkirim (flg_vitek3 = False/NULL) - - Kirim order via HL7 MLLP - - Tandai terkirim jika ACK diterima - """ - while True: - conn = None - incoming_buffer = b"" - last_idle_log_at = 0.0 - try: - print(f"[MYLA-CLIENT] Mencoba konek ke MYLA {host}:{port} ...") - conn = socket.create_connection((host, port), timeout=10) - conn.settimeout(1.0) - print(f"[MYLA-CLIENT] Terhubung ke MYLA {host}:{port}") - - while True: - incoming_buffer = pump_myla_incoming(conn, host, incoming_buffer, wait_seconds=0.15) - pending_orders = get_pending_myla_orders(limit=10) - if not pending_orders: - now_ts = time.time() - if (now_ts - last_idle_log_at) >= MYLA_IDLE_LOG_INTERVAL_SECONDS: - print( - "[MYLA-CLIENT] Polling aktif: tidak ada order pending " - "(flg_vitek3 = FALSE/NULL)." - ) - last_idle_log_at = now_ts - time.sleep(MYLA_POLL_INTERVAL_SECONDS) - continue - - print(f"[MYLA-CLIENT] Ditemukan {len(pending_orders)} order pending untuk MYLA") - last_idle_log_at = 0.0 - - for order in pending_orders: - send_ok = send_order_to_myla_hl7(conn, order, host) - if send_ok: - mark_myla_order_sent(order.urut) - print(f"[MYLA-HL7] Order sukses, set flg_vitek3=TRUE rnoreg={order.rnoreg}") - else: - logging.warning( - f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " - f"akan dicoba ulang di polling berikutnya." - ) - print( - f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " - f"akan dicoba ulang di polling berikutnya." - ) - break - - time.sleep(1) - - except Exception as e: - logging.error(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") - print(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") - finally: - if conn: - try: - conn.close() - except Exception: - pass - - time.sleep(MYLA_CONNECT_RETRY_SECONDS) - -def start_myla_inbound_server(host, port): - """ - Listener TCP inbound untuk menerima hasil dari connector AI_to_LIS_MyLis (BCI/MyLA). - """ - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - server.bind((host, port)) - server.listen(5) - print(f"[MYLA-INBOUND] Listening di {host}:{port}") - - while True: - client_socket, addr = server.accept() - client_thread = threading.Thread( - target=handle_myla_client, - args=(client_socket, addr), - daemon=True - ) - client_thread.start() - except Exception as e: - logging.critical(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") - print(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") - finally: - try: - server.close() - except Exception: - pass - -# ========================================== -# VITEK PARSER -# ========================================== - -def parse_and_save_vitek_result(raw_data, port_name="VITEK"): - session = SessionLocal() - try: - # --- 1. CLEANING DATA --- - # Hapus karakter kontrol STX(02), ETX(03), RS(1e/30), GS(1d/29), CR, LF - # Perhatikan: Log Anda menunjukkan RS () muncul di tengah kata, jadi harus dihapus total. - clean_data = raw_data.replace('\x02', '').replace('\x03', '').replace('\x1e', '').replace('\x1d', '').replace('\r', '').replace('\n', '') - - # Pisahkan field berdasarkan pipa '|' - fields = clean_data.split('|') - - # Cek apakah ini pesan result (mtrsl) - if not fields or fields[0] != 'mtrsl': - return # Abaikan jika bukan result - - # --- 2. VARIABLE INIT --- - sample_id = None # ci (No Lab / No Container) - patient_id = "" # pi (No RM) - patient_name = "" # pn - organism_name = "" # o2 - card_barcode = "" # is - antibiotics = [] # List penampung hasil AB - result_date = datetime.datetime.now() - - # Variabel sementara untuk looping antibiotik - curr_ab_name = "" - curr_ab_mic = "" - curr_ab_int = "" - - # --- 3. PARSING LOOP --- - for field in fields: - if not field: continue - - # --- HEADER INFO --- - if field.startswith("ci") and len(field) > 2: - sample_id = field[2:].strip() - elif field.startswith("pi") and len(field) > 2: - patient_id = field[2:].strip() - elif field.startswith("pn") and len(field) > 2: - patient_name = field[2:].strip() - elif field.startswith("is") and len(field) > 2: - card_barcode = field[2:].strip() - # --- ORGANISME --- - elif field.startswith("o2") and len(field) > 2: - organism_name = field[2:].strip() - - # --- ANTIBIOTIK BLOCK (Mulai Pesan Kedua) --- - # Tag 'ra' adalah pemisah antar obat - elif field == "ra": - # Jika ada data obat sebelumnya di memori, simpan dulu - if curr_ab_name: - ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" - antibiotics.append(ab_str) - # Reset untuk obat berikutnya - curr_ab_name = ""; curr_ab_mic = ""; curr_ab_int = "" - - # Detail Obat - elif field.startswith("a2") and len(field) > 2: # Nama Obat (mis: Cefoxitin) - curr_ab_name = field[2:].strip() - elif field.startswith("a3") and len(field) > 2: # MIC (mis: >=4) - curr_ab_mic = field[2:].strip() - elif field.startswith("a4") and len(field) > 2: # Interpretasi (R/S/I) - curr_ab_int = field[2:].strip() - elif field.startswith("an") and len(field) > 2: # Interpretasi Alternatif - if not curr_ab_int: curr_ab_int = field[2:].strip() - - # Jangan lupa simpan obat terakhir yang tersisa di buffer - if curr_ab_name: - ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" - antibiotics.append(ab_str) - - # --- 4. FORMAT FINAL STRING --- - # Format: "Staphylococcus hominis | Cefoxitin >=4 (R), Gentamicin 4 (S)..." - final_res_string = organism_name - #if antibiotics: - # final_res_string += " | " + ", ".join(antibiotics) - - # Fallback jika negatif (biasanya tidak ada o2, tapi ada teks neg) - #if not final_res_string and "neg" in raw_data.lower(): - # final_res_string = "NEGATIVE / NO GROWTH" - - # --- 5. LOGIKA DATABASE (UPSERT: UPDATE or INSERT) --- - if sample_id: - # Cari data berdasarkan No Lab (Sample ID) - final_seq_no = card_barcode if card_barcode else patient_id - existing_data = session.query(LisPhoenix).filter( - LisPhoenix.seq_no == final_seq_no - ).first() - - if existing_data: - # === SKENARIO PESAN KEDUA (UPDATE) === - print(f"[{port_name}] UPDATE Data -> ID: {sample_id} (Hasil Lengkap)") - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=final_seq_no, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - - else: - # === SKENARIO PESAN PERTAMA (INSERT) === - print(f"[{port_name}] INSERT Data -> ID: {sample_id} (Identifikasi Awal)") - - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=final_seq_no, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - - session.commit() - else: - logging.warning(f"[{port_name}] Pesan diabaikan (Tanpa Sample ID): {clean_data[:30]}...") - - except Exception as e: - logging.error(f"Error Parsing Vitek: {e}") - print(f"Error Parsing Vitek: {e}") - session.rollback() - finally: - session.close() - -def split_patient_name(full_name): - """ - Return: last_name, first_name - ASTM format: LAST^FIRST - """ - - if not full_name: - return "NAME", "NO" - - raw = str(full_name).strip() - - if not raw: - return "NAME", "NO" - - first_name = "" - last_name = "" - - # Hapus karakter ASTM berbahaya - raw = ( - raw.replace("\x00", "") - .replace("\r", " ") - .replace("\n", " ") - .replace("|", " ") - .replace("&", " ") - ) - - if "^" in raw: - parts = [p.strip() for p in raw.split("^")] - - last_name = parts[0] if len(parts) > 0 and parts[0] else "NAME" - first_name = parts[1] if len(parts) > 1 and parts[1] else "NO" - - else: - tokens = raw.split() - - if len(tokens) >= 2: - first_name = " ".join(tokens[:-1]) - last_name = tokens[-1] - elif len(tokens) == 1: - first_name = tokens[0] - last_name = "NAME" - - first_name = first_name.upper()[:20] - last_name = last_name.upper()[:20] - - return last_name, first_name - -def sanitize_astm_field(value, *, uppercase=False, max_len=None, allow_component_sep=False): - """ - Sanitasi field agar aman untuk ASTM: - - Buang karakter NULL/control (termasuk CR/LF) - - Hilangkan delimiter ASTM pada field biasa - - Trim dan optional uppercase/truncate - """ - if value is None: - text = "" - else: - text = str(value) - - # Hilangkan NULL byte yang sering muncul dari CHAR/VARCHAR bermasalah. - text = text.replace("\x00", "") - # Buang karakter kontrol non-printable. - text = re.sub(r"[\x00-\x1f\x7f]", " ", text) - - if allow_component_sep: - text = text.replace("|", " ") - else: - text = text.replace("|", " ").replace("^", " ") - - text = re.sub(r"\s+", " ", text).strip() - - # ASTM payload dikirim dengan latin-1; karakter di luar rentang ini diganti aman. - text = text.encode("latin-1", errors="replace").decode("latin-1") - - if uppercase: - text = text.upper() - if max_len is not None: - text = text[:max_len] - return text - -def create_vitek_order_message(order): - """ - Membuat Frame Order Vitek sesuai Manual Ref 514937. - Format: [DATA] [CS] - """ - # --- 1. ISI PESAN (CONTENT) --- - # Field Delimiter menggunakan Pipe '|' - pid = str(order.norm).strip() if order.norm else "" - sid = str(order.rnoreg).strip() if order.rnoreg else "" - first_name, last_name = split_patient_name(order.nama) - p_name = f"{last_name}^{first_name}" - specimen = str(order.nm_spesimen).upper() if order.nm_spesimen else "BLOOD" - room = str(getattr(order, 'ruangan', "") or "RSSA MALANG").strip().upper() - # VITEK: Patient Location Code menggunakan tag "pl" (maks 40 char pada spec yang dipakai) - room = room.replace("|", " ").replace("^", " ")[:40] - - now = datetime.datetime.now() - date_str = now.strftime("%m/%d/%Y") - time_str = now.strftime("%H:%M") - - # Struktur mtmpr sesuai Table 1-3 & Contoh Manual - # Penting: Tidak ada Sequence Number '1' di dalam data - content_body = ( - f"mtmpr|pi{pid}|pn{p_name}" - f"|si|ss{specimen}" - f"|pl{room}" - f"|s1{date_str}|s2{time_str}" - f"|ci{sid}|t11|zz" - ) - - # --- 2. FRAMING & CHECKSUM (Section 2.2 & 2.5) --- - # Frame dimulai dengan STX, lalu Record dimulai dengan RS - STX = b'\x02' - RS = b'\x1e' - GS = b'\x1d' - ETX = b'\x03' - CRLF = b'\r\n' - - # Data yang dihitung Checksumnya: [RS] + [Body] + [GS] - # Manual Hal 2-10: "calculated by adding the value of all characters beginning with the first ... and ending with the " - payload_for_checksum = RS + content_body.encode('latin-1') + GS - - # Hitung Checksum - total_sum = sum(payload_for_checksum) - chk_val = total_sum % 256 - checksum_str = f"{chk_val:02X}".encode('latin-1') # Hex 2 digit uppercase - - # Rakit Frame Utuh - # [PAYLOAD+GS] [CHECKSUM] - # Perhatikan: Payload di atas sudah mengandung RS dan GS - full_frame = STX + payload_for_checksum + checksum_str + ETX + CRLF - - return [full_frame] - -def manage_vitek_port(config): - port_name = config['port'] - flag_col = config.get('flag_column') - alat_name = config.get('alat_name', 'VITEK') - - print(f"[{port_name}] START VITEK SERVICE (Relaxed Mode)...") - STUCK_WINDOW_SEC = 120 - stuck_count = 0 - stuck_window_start = None - while True: - try: - with serial.Serial( - port=port_name, - baudrate=config['baud_rate'], - timeout=2, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - xonxoff=False, - rtscts=False, - dsrdtr=False - ) as ser: - - ser.reset_input_buffer() - print(f"[{port_name}] Ready & Listening.") - - while True: - # ========================================== - # PHASE 1: LISTENING - # ========================================== - if ser.in_waiting > 0: - header = ser.read(1) - - # --- HANDSHAKE --- - if header == b'\x05': - print(f"[{port_name}] Got ENQ -> Reply ACK") - ser.write(b'\x06') - ser.reset_input_buffer() - - # --- DATA FRAME --- - elif header == b'\x02': - print(f"[{port_name}] Frame Start. Reading...") - original_timeout = ser.timeout - ser.timeout = 8 - body = ser.read_until(b'\x03') - ser.timeout = original_timeout - - full_frame = header + body - - - is_valid = False - - if full_frame.endswith(b'\x03'): - is_valid = True - elif b'\x1d' in full_frame[-20:]: - is_valid = True - print(f"[{port_name}] Frame tanpa ETX tapi ada Checksum. Menerima paksa...") - if is_valid: - print(f"[{port_name}] Frame OK -> ACK Sent.") - # 1. KIRIM ACK (WAJIB) - ser.write(b'\x06') - stuck_count = 0 - stuck_window_start = None - - # 2. Proses Data - try: - full_str = full_frame.decode('latin-1', errors='ignore') - # Debug - # print(f"[{port_name}] CONTENT: {full_str}") - parse_and_save_vitek_result(full_str, alat_name) - except Exception as e: - logging.error(f"[{port_name}] Parse Err: {e}") - print(f"[{port_name}] Parse Err: {e}") - else: - logging.warning(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") - print(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") - ser.write(b'\x15') # NAK - - # --- EOT --- - elif header == b'\x04': - logging.info(f"[{port_name}] Session End (EOT).") - print(f"[{port_name}] Session End (EOT).") - ser.reset_input_buffer() - stuck_count = 0 - stuck_window_start = None - - else: - pass - - # ========================================== - # PHASE 2: SENDING ORDER (JIKA IDLE) - # ========================================== - else: - # Kita masuk sini jika ser.in_waiting == 0 (Sepi) - # Pastikan kolom flag diset di config - if flag_col: - session = None - try: - session = SessionLocal() - # Cari order yang belum dikirim - pending_order = session.query(PaslabOrder).filter( - getattr(PaslabOrder, flag_col) == False - ).first() - - if pending_order: - print(f"[{port_name}] Ada Order: {pending_order.rnoreg}...") - - # --- LOGIC HANDSHAKE DENGAN RETRY --- - handshake_success = False - - # Coba kirim ENQ max 3 kali - for attempt in range(3): - ser.reset_input_buffer() - ser.write(b'\x05') # Kirim ENQ - time.sleep(0.5) # Tunggu balasan - - if ser.in_waiting: - resp = ser.read(1) - if resp == b'\x06': # Dapat ACK - handshake_success = True - break - elif resp == b'\x15': # Dapat NAK - time.sleep(1) - else: - # Timeout, alat diam saja - pass - - if handshake_success: - stuck_count = 0 - stuck_window_start = None - # === KIRIM DATA ORDER === - print(f"[{port_name}] Handshake OK. Kirim Frames...") - frames = create_vitek_order_message(pending_order) - all_sent = True - - for frame in frames: - ser.write(frame) - # Tunggu ACK per frame - got_ack = False - wait_start = time.time() - while time.time() - wait_start < 3: - if ser.in_waiting: - if ser.read(1) == b'\x06': - got_ack = True - break - - if not got_ack: - all_sent = False - break - - # Tutup Sesi - ser.write(b'\x04') # EOT - - if all_sent: - print(f"[{port_name}] Order SELESAI Terkirim.") - setattr(pending_order, flag_col, True) - session.commit() - stuck_count = 0 - stuck_window_start = None - else: - logging.error(f"[{port_name}] Order Gagal (No ACK).") - print(f"[{port_name}] Order Gagal (No ACK).") - - else: - # === FORCE RESET (ANTI-STUCK) === - # Jika sudah 3x ENQ tidak dibalas, anggap alat 'bengong' - # Kirim EOT untuk mereset status alat - print(f"[{port_name}] Alat Sibuk/Stuck. Kirim Force EOT.") - ser.write(b'\x04') - time.sleep(2.0) - now_ts = time.time() - if not stuck_window_start or (now_ts - stuck_window_start) > STUCK_WINDOW_SEC: - stuck_window_start = now_ts - stuck_count = 1 - else: - stuck_count += 1 - if stuck_count >= 3: - # Jika sudah stuck berulang dalam window waktu, restart koneksi serial - logging.critical( - f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." - ) - print( - f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." - ) - try: - ser.reset_input_buffer() - ser.reset_output_buffer() - except Exception: - pass - raise RuntimeError("Vitek stuck berulang, restart port.") - - except Exception as e: - logging.error(f"[{port_name}] Sending Error: {e}") - print(f"[{port_name}] Sending Error: {e}") - finally: - if session: session.close() - - # Sleep penting agar CPU tidak 100% saat idle - time.sleep(0.1) - - - except Exception as e: - logging.critical(f"[{port_name}] Serial Crash: {e}") - print(f"[{port_name}] Serial Crash: {e}") - time.sleep(5) - -# ========================================== -# BECTON DICKINSON (BD) BACTEC PARSER -# ========================================== - -def parse_and_save_bd_result(raw_data, port_name="BD Bactec"): - session = SessionLocal() - try: - # --- LANGKAH 1: REASSEMBLY (JAHIT FRAME) --- - raw_frames = raw_data.split('\x02') - full_content = "" - - for frame in raw_frames: - if not frame: continue - content_chunk = frame - # Hapus Checksum & ETX/ETB - if '\x03' in frame: content_chunk = frame.split('\x03')[0] - elif '\x17' in frame: content_chunk = frame.split('\x17')[0] - - # Hapus Sequence Number di awal - if content_chunk and content_chunk[0].isdigit(): - content_chunk = content_chunk[1:] - - full_content += content_chunk - - # --- LANGKAH 2: PARSING FIELD --- - lines = full_content.split('\r') - - sample_id = None - patient_id = "" - patient_name = "" - specimen_type = "" - result_val = "" - result_date = datetime.datetime.now() - - for line in lines: - line = line.strip() - if not line: continue - fields = line.split('|') - record_type = fields[0] - - # --- PATIENT (P) --- - if record_type == 'P': - # P|Seq|?|PID||Name - if len(fields) > 3: patient_id = fields[3].strip() - if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() - - # --- ORDER (O) --- - elif record_type == 'O': - # O|Seq|SampleID|...|...|...|...|...|...|...|...|...|...|...|Specimen - if len(fields) > 2: - sample_id = fields[2].replace('^', '').strip() - - # Ambil Spesimen (Biasanya di index 15 / Field 16) - if len(fields) > 15: - specimen_type = fields[15].strip() - - # --- RESULT (R) --- - elif record_type == 'R': - # R|Seq|Test|Result|...|...|...|...|...|...|StartDate|EndDate - if len(fields) > 3: - raw_res = fields[3].strip() - # Bersihkan hasil - if "NEGATIVE" in raw_res: result_val = "NEGATIVE" - elif "POSITIVE" in raw_res: result_val = "POSITIVE" - else: result_val = raw_res.split('^')[0] # Ambil kode depan saja - - # Ambil Tanggal Hasil Selesai (Index 12 / Field 13) - if len(fields) > 12 and len(fields[12]) >= 14: - try: - # Format BD: YYYYMMDDHHMMSS (20251006100257) - res_dt_str = fields[12][:14] - result_date = datetime.datetime.strptime(res_dt_str, "%Y%m%d%H%M%S") - except: - pass # Gunakan default jika gagal parse - - # --- LANGKAH 3: FORMAT FINAL & SAVE --- - if sample_id and result_val: - - final_res_string = result_val - if specimen_type: - final_res_string += f" ({specimen_type})" - - print(f"[{port_name}] Save DB -> ID: {sample_id}, Pasien: {patient_name}, Hasil: {final_res_string}") - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - session.commit() - else: - logging.warning(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") - print(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") - - except Exception as e: - logging.error(f"Error Parsing BD: {e}") - print(f"Error Parsing BD: {e}") - session.rollback() - finally: - session.close() - -def calculate_astm_checksum(frame_content): - data_bytes = frame_content.encode('latin-1') - checksum = sum(data_bytes) % 256 - return f"{checksum:02X}" - -def create_astm_order_message(order): - """ - Membuat 1 Frame ASTM Single Block (H, P, O, L) dengan Mapping Index PRESISI. - Menghindari pergeseran kolom (shifting error). - """ - # --- 1. PERSIAPAN DATA --- - pid = sanitize_astm_field(order.norm, max_len=32) - sid = sanitize_astm_field(order.rnoreg, max_len=32) - # Nama pasien dipisah agar mengikuti format ASTM: Last^First - first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) - first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) - last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) - p_name = f"{last_name}^{first_name}" - sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) - sex = "M" if sex_raw.startswith("L") else "F" - - # Lokasi / Ruangan (Field 26) sebaiknya berupa kode singkat agar tidak ditrunkasi LIS. - raw_location = sanitize_astm_field(getattr(order, 'ruangan', "UT"), uppercase=True, max_len=10) - location = re.sub(r"[^A-Z0-9]", "", raw_location)[:10] - if not location: - location = "UT" - - # Diagnosis (Clinical Info - Field 14 di ASTM standar atau 13 di beberapa varian) - # Kita pasang di Index 13 (Field 14) agar aman - diagnosis = sanitize_astm_field(getattr(order, 'diagnosa', "Unspecified"), max_len=60) - if not diagnosis: diagnosis = "Unspecified" - - # Specimen Info (Field 16) - # Format: SpecimenType^BodySite^Container^Condition - specimen_type = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) if order.kd_spesimen else "BLOOD" - body_site = "VENA" # Site - condition = "BAIK" # Condition - specimen_field = f"{specimen_type}^{body_site}^^{condition}" - - # --- 2. KONSTRUKSI RECORD DENGAN INDEX PASTI --- - - # --- RECORD HEADER (H) --- - h_rec = [""] * 14 - - h_rec[0] = "H" # H,1 Record Type - h_rec[1] = r"\^&" # H,2 Delimiter - h_rec[4] = "MyLIS" # H,5 Sender Name - h_rec[12] = "V1.00" # H,13 Version - h_rec[13] = datetime.datetime.now().strftime("%Y%m%d%H%M%S") # H,14 Message DateTime - - head = "|".join(h_rec) - # --- RECORD PATIENT (P) --- - # Kita buat array kosong sebanyak 30 kolom dulu - p_rec = [""] * 35 - p_rec[0] = "P" # Field 1: Record Type - p_rec[1] = "1" # Field 2: Sequence - p_rec[2] = pid # Field 3: Patient ID (Practice) - p_rec[3] = pid # Field 4: Lab ID (Kosong) - p_rec[4] = "" # Field 5: ID 3 (Kosong) - p_rec[5] = p_name # Field 6: Patient Name (Index 5) <--- SEBELUMNYA SALAH DISINI - p_rec[7] = "" # Field 8: Birthdate - p_rec[8] = sex # Field 9: Sex (Index 8) - # ... Field 10-25 biarkan kosong ... - p_rec[25] = "1" # Field 26: Location (Index 25) - p_rec[32] = "MIKRO" # Hospital Service - p_rec[33] = raw_location# Hospital Client (Raw, untuk referensi internal) - - # Potong array sampai index 26 saja (sisanya buang) lalu gabung - pat_str = "|".join(p_rec[:35]) - - # --- RECORD ORDER (O) --- - o_rec = [""] * 30 - o_rec[0] = "O" # Field 1 - o_rec[1] = "1" # Field 2 - o_rec[2] = sid # Field 3: Sample ID - o_rec[3] = "" # Field 4: Instrument Specimen ID - o_rec[4] = "^^^" # Field 5: Universal Test ID - o_rec[5] = "R" # Field 6: Priority - # ... Field 7-11 ... - o_rec[11] = "A" # Field 12: Action Code (A=Add, N=New) (Index 11) - o_rec[12] = diagnosis # Field 13: Clinical Info / Diagnosis (Index 12) - # ... Field 14-15 ... - o_rec[15] = specimen_field # Field 16: Specimen Source (Index 15) - - # Potong array sampai index 16 (atau lebih jika BD butuh field belakang) - # Kita ambil aman sampai field 20 - ord_str = "|".join(o_rec[:20]) - - # --- RECORD TERMINATOR (L) --- - term = "L|1|N" - - # --- 3. GABUNG FRAME --- - # Gunakan \r (Carriage Return) sebagai pemisah record - message_content = f"{head}\r{pat_str}\r{ord_str}\r{term}" - - # Sequence Frame = 1 - seq = "1" - - # Isi Frame: [Seq] [Data] [ETX] - frame_body = f"{seq}{message_content}\r\x03" - - # Hitung Checksum - chk = calculate_astm_checksum(frame_body) - - # Full Frame - full_frame = f"\x02{frame_body}{chk}\r\n" - - return [full_frame.encode('latin-1')] - -def manage_bd_port(config): - port_name = config['port'] - flag_col = config.get('flag_column') - alat_name = config.get('alat_name', 'BD') - print(f"[{port_name}] Membuka port untuk alat {alat_name}...") - - # Buffer untuk menampung pecahan data - rx_buffer = "" - - try: - with serial.Serial( - port=port_name, - baudrate=config['baud_rate'], - timeout=1 - ) as ser: - - while True: - has_activity = False # Penanda agar kita sleep kalau sepi - - # ========================================== - # PHASE 1: LISTENING (PRIORITAS UTAMA) - # ========================================== - try: - if ser.in_waiting > 0: - has_activity = True - data_chunk = ser.read(ser.in_waiting or 1024) - - if data_chunk: - try: - # 1. Handle Handshake Awal (ENQ) - if b'\x05' in data_chunk: - print(f"[{port_name}] Terima ENQ (Alat mau kirim data). Kirim ACK.") - ser.write(b'\x06') # ACK - rx_buffer = "" # Reset buffer untuk data baru - data_chunk = data_chunk.replace(b'\x05', b'') - - # 2. Simpan semua byte data yang tersisa. - # Chunk lanjutan frame bisa datang tanpa STX, jadi tidak boleh dibuang. - chunk_without_eot = data_chunk.replace(b'\x04', b'') - if chunk_without_eot: - rx_buffer += chunk_without_eot.decode('latin-1', errors='ignore') - - # 3. Handle Data Frame Normal (STX ... ETX/ETB) - # ASTM butuh ACK setiap frame baru agar alat lanjut kirim frame berikutnya. - if b'\x02' in data_chunk: - ser.write(b'\x06') - # logging.debug(f"[{port_name}] Frame diterima, ACK dikirim.") - print(f"[{port_name}] Frame diterima, ACK dikirim.") - - # 4. Handle Akhir Transmisi (EOT) - if b'\x04' in data_chunk: - print(f"[{port_name}] Terima EOT (Selesai). Memproses data...") - parse_and_save_bd_result(rx_buffer, alat_name) - rx_buffer = "" # Kosongkan buffer setelah save - - except Exception as e: - logging.error(f"Error decode frame: {e}") - print(f"Error decode frame: {e}") - - continue # Loop lagi untuk ambil sisa data - - except Exception as e: - logging.error(f"[{port_name}] Error Reading: {e}") - print(f"[{port_name}] Error Reading: {e}") - rx_buffer = "" # Reset jika error parah - - - # ========================================== - # PHASE 2: SENDING ORDER (JIKA BUFFER KOSONG) - # ========================================== - # Kita hanya kirim order jika sedang tidak menerima data (buffer kosong) - if not rx_buffer and flag_col: - try: - session = SessionLocal() - # Cari order yang belum dikirim - pending_order = session.query(PaslabOrder).filter( - getattr(PaslabOrder, flag_col) == False - ).first() - - if pending_order: - has_activity = True # Jangan sleep lama-lama - print(f"[{port_name}] Menemukan Order: {pending_order.rnoreg}. Memulai Handshake...") - # --- STEP 1: HANDSHAKE (ENQ) --- - ser.reset_input_buffer() - ser.write(b'\x05') - time.sleep(0.5) - - ack_response = ser.read(1) - - if ack_response == b'\x06': - print(f"[{port_name}] Handshake Sukses (Dapat ACK). Menunggu alat siap...") - # --- PERBAIKAN 1: BERI JEDA SETELAH HANDSHAKE --- - # Mesin butuh napas sebelum terima data panjang - time.sleep(1.5) # Jeda 1.5 detik - - frames = create_astm_order_message(pending_order) - all_frames_sent = True - - # --------------------------------------------------- - # STEP 2: SEND FRAMES WITH RETRY - # --------------------------------------------------- - for i, frame in enumerate(frames): - retry_count = 0 - max_retries = 3 - frame_success = False - - while retry_count < max_retries: - ser.reset_input_buffer() - - print(f"[{port_name}] Kirim Frame {i+1} (Percobaan {retry_count+1})...") - ser.write(frame) - - # Tunggu ACK - original_timeout = ser.timeout - ser.timeout = 3 # Beri waktu agak lama (3 detik) untuk alat memproses data - frame_ack = ser.read(1) - ser.timeout = original_timeout - - if frame_ack == b'\x06': # ACK (Sukses) - frame_success = True - print(f"[{port_name}] Frame {i+1} ACK diterima.") - # Beri jeda dikit sebelum kirim frame berikutnya - time.sleep(0.2) - break - - elif frame_ack == b'\x15': # NAK (Ditolak - Checksum Salah) - print(f"[{port_name}] Frame ditolak (NAK). Checksum mungkin salah.") - time.sleep(2) # Tunggu 2 detik - retry_count += 1 - - elif not frame_ack: # Timeout (Sepi) - # --- PERBAIKAN 2: BERI JEDA SAAT TIMEOUT --- - print(f"[{port_name}] Timeout (Alat diam). Menunggu sebelum retry...") - time.sleep(2) # Tunggu 2 detik agar alat recover - retry_count += 1 - - else: - logging.warning(f"[{port_name}] Respon aneh: {frame_ack}") - print(f"[{port_name}] Respon aneh: {frame_ack}") - time.sleep(1) - retry_count += 1 - - if not frame_success: - logging.error(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") - print(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") - all_frames_sent = False - break - # --------------------------------------------------- - # STEP 3: FINALIZE - # --------------------------------------------------- - if all_frames_sent: - ser.write(b'\x04') # EOT (End of Transmission) - print(f"[{port_name}] Order {pending_order.rnoreg} SUKSES Terkirim.") - # Update Database - setattr(pending_order, flag_col, True) - session.commit() - else: - ser.write(b'\x04') # EOT (Putus paksa karena error) - logging.error(f"[{port_name}] Pengiriman Order GAGAL.") - print(f"[{port_name}] Pengiriman Order GAGAL.") - else: - # Jika Handshake gagal (Dibalas NAK, atau Timeout) - logging.warning(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") - print(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") - # Jangan update flag DB, biarkan coba lagi nanti - - - session.close() - - except Exception as e: - logging.error(f"[{port_name}] Error Sending Logic: {e}") - print(f"[{port_name}] Error Sending Logic: {e}") - if 'session' in locals(): session.close() - - # ========================================== - # PHASE 3: IDLE MANAGEMENT - # ========================================== - # Jika tidak ada data masuk dan tidak ada order keluar, tidur sebentar - # Ini penting agar CPU tidak 100% dan DB tidak jebol - if not has_activity: - time.sleep(1.0) - - except Exception as e: - logging.critical(f"[{port_name}] Gagal connect Serial: {e}") - print(f"[{port_name}] Gagal connect Serial: {e}") - time.sleep(5) - -# ========================================== -# 5. Serial Manager -# ========================================== - -def manage_serial_port(config): - """Fungsi router yang memilih manajer yang tepat berdasarkan tipe alat.""" - device_type = config.get('device_type') - if device_type == 'vitek': - manage_vitek_port(config) - elif device_type in ['bd_mgit', 'bd_bactec', 'bd']: - manage_bd_port(config) - else: - print(f"Tipe alat tidak diketahui: '{device_type}' untuk port {config.get('port')}. Thread dihentikan.") - -# ========================================== -# 6. MAIN EXECUTION -# ========================================== -if __name__ == "__main__": - print("--- MEMULAI LIS INTERFACE SYSTEM ---") - - # List untuk menampung semua thread agar bisa dimonitor - all_threads = [] - stop_event = threading.Event() - - # 1. Start Thread Serial Manager (Vitek & BD) - for config in DEVICE_CONFIGS: - if config['protocol'] == 'serial': - t_serial = threading.Thread( - target=manage_serial_port, - args=(config,), - name=f"Manager-{config['device_type']}-{config['port']}", - daemon=True - ) - t_serial.start() - all_threads.append(t_serial) - - # 2. Start Thread TCP Server (GeneXpert) - t_tcp = threading.Thread(target=manage_tcp_server, name="Manager-TCP-GeneXpert", daemon=True) - t_tcp.start() - all_threads.append(t_tcp) - - # 3. Start Thread TCP Client (LIS -> MyLA: kirim order) - myla_thread = threading.Thread( - target=start_myla_server, - args=(MYLA_HOST, MYLA_PORT), - name="Manager-TCP-MyLA-Client", - daemon=True - ) - myla_thread.start() - all_threads.append(myla_thread) - - # 3. Start Thread TCP Server Inbound (MyLA/BCI -> LIS: kirim hasil) - myla_inbound_thread = threading.Thread( - target=start_myla_inbound_server, - args=(MYLA_INBOUND_HOST, MYLA_INBOUND_PORT), - name="Manager-TCP-MyLA-Inbound", - daemon=True - ) - myla_inbound_thread.start() - all_threads.append(myla_inbound_thread) - - # 4. LOOP UTAMA (Keep-Alive & Monitoring) - try: - while True: - print(f"--- Monitoring {len(all_threads)} Threads ---") - alive_count = 0 - for t in all_threads: - if t.is_alive(): - alive_count += 1 - else: - print(f"!!! THREAD MATI: {t.name} !!!") - - if alive_count == 0: - print("Semua thread mati. System Shutdown.") - break - - time.sleep(10) - - except KeyboardInterrupt: - print("Mematikan Service (Ctrl+C)...") - stop_event.set() diff --git a/listener/geneexpert.py b/listener/geneexpert.py deleted file mode 100644 index a7861796..00000000 --- a/listener/geneexpert.py +++ /dev/null @@ -1,3834 +0,0 @@ -import builtins -from enum import Enum -from logging import config -from logging.handlers import TimedRotatingFileHandler -import os -from queue import Queue -import re -import socket -import logging -from sqlite3 import Date -import threading -import time -import datetime -import traceback -import serial # type: ignore - -from flask import Flask, jsonify, request # type: ignore -from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text # type: ignore -from sqlalchemy import DateTime as SqDateTime # type: ignore -from sqlalchemy import Date as SqDate # type: ignore -from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore -# Logging Setup -# Konfigurasi logging per hari -log_handler = TimedRotatingFileHandler( - filename="geneexpert.log", - when="midnight", - interval=1, - backupCount=7, - encoding="utf-8" -) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s') -log_handler.setFormatter(formatter) -logging.basicConfig(level=logging.INFO, handlers=[log_handler]) - -THREAD_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "thread_logs") -thread_log_lock = threading.Lock() - -def _sanitize_thread_log_name(thread_name): - safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(thread_name or "main").strip()) - return safe_name or "main" - -def _write_thread_log(message): - try: - os.makedirs(THREAD_LOG_DIR, exist_ok=True) - thread_name = threading.current_thread().name - safe_thread_name = _sanitize_thread_log_name(thread_name) - log_path = os.path.join(THREAD_LOG_DIR, f"{safe_thread_name}.log") - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with thread_log_lock: - with open(log_path, "a", encoding="utf-8") as fh: - fh.write(f"{timestamp} | {message}\n") - except Exception: - pass - -def print(*args, **kwargs): - sep = kwargs.get("sep", " ") - end = kwargs.get("end", "\n") - message = sep.join(str(arg) for arg in args) - _write_thread_log(message) - return builtins.print(*args, **kwargs) - -def _visible_bytes(data: bytes) -> str: - mapping = { - 0x02: "", - 0x03: "", - 0x04: "", - 0x05: "", - 0x06: "", - 0x0D: "", - 0x0A: "", - 0x17: "", - 0x15: "", - 0x1C: "", - 0x0B: "", - } - parts = [] - for b in data: - if b in mapping: - parts.append(mapping[b]) - elif 32 <= b <= 126: - parts.append(chr(b)) - else: - parts.append(f"<0x{b:02X}>") - return "".join(parts) - -def _hex_bytes(data: bytes, limit: int = 160) -> str: - clipped = data[:limit] - text = clipped.hex().upper() - return text + ("..." if len(data) > limit else "") - -# ========================================== -# 1. KONFIGURASI SISTEM -# ========================================== -# Global Variables -app = Flask(__name__) -active_genexpert_connections = {} -connection_lock = threading.Lock() -pending_result_queries = {} -pending_query_lock = threading.Lock() -scheduled_result_queries = {} -scheduled_result_query_lock = threading.Lock() -genexpert_query_inflight_by_ip = {} -genexpert_query_inflight_lock = threading.Lock() -# Network Configuration -TCP_LISTENER_PORT = 6001 # PC GeneXpert set ke mode Client, konek ke IP:PORT ini -SERVER_HOST = '0.0.0.0' # Listen di semua interface -HTTP_API_PORT = 6002 # Endpoint trigger dari Laravel -> Python -GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS = 60 -GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS = 120 -GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS = 21600 -GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS = 45 -GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER = False -GENEXPERT_RESPONSE_MODE_DEFAULT = "astm_active" -GENEXPERT_RESPONSE_MODE_BY_IP = { - # "10.10.120.75": "hl7_passive", -} -GENEXPERT_HOST_APPLICATION_DEFAULT = "DE002" -GENEXPERT_HOST_APPLICATION_BY_IP = { - "10.10.120.75": "GE01", # GenExpert Kecil - "10.10.120.108": "DE002", # GenExpert Tempat Lama - "10.10.120.73": "GE01", # GenExpert Besa -} -# Mapping Flag ke IP Address GeneXpert -# Pastikan IP ini SESUAI dengan settingan "Server IP" di masing-masing alat (Client Mode) -TARGET_MAPPING = { - 'flg_gxp1': '10.10.120.73', - 'flg_gxp2': '10.10.120.108', - 'flg_gxp3': '10.10.120.75' -} -# GeneXpert Configuration -# ========================================== -# KONFIGURASI MAPPING TES (DATABASE -> GENEXPERT) -# ========================================== -# Kiri: Nama di kolom 'tes' database Anda -# Kanan: 'Host Test Code' dari Dokumen Word Anda -GENEXPERT_TEST_MAPPING = { - # Mapping untuk IP 10.10.120.75 (Multi-Assay) - "HIV": "HIV1-VL", - "HBV": "HBVVL", - "TCM TB": "MTBRIF", - "TCM TB ULTRA": "MTBRIF", - "TCM TB XDR": "MTB-XDR 2", - "HCV VL": "HCV", - "COVID-19": "SARSCOV2FLURSV", - "17.3.1 TCM COVID-19": "SARSCOV2FLURSV", - "17.3.2 PCR COVID-19": "SARSCOV2FLURSV", - "E.2.5 HCV TCM": "HCV", - "18.1.1 TCM HCV": "HCV", - "18.1.2 TCM HIV VIRAL LOAD": "HIV1-VL", - "18.1.4 TCM HPV": "HPV", - "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", - "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", - "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR 2", - "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "1.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "1.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "15.2.1 KULTUR TB MEDIA LJ": "MTB-XDR 2", - "8.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "9.3.5 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "9.3.6 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "12.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "H.2.5 PEMERIKSAAN KULTUR MYCROBACTERIUM TBC": "MTB-XDR 2", - "12.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "11.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "10.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "10.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "3.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "15.2.2 KULTUR TB MEDIA MGIT (AUTOMATIC)": "MTB-XDR 2", - "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", - "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", - "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTBRIF", - "12.3.8 TCM TB (GENE EXPERT)": "MTBRIF", - "15.2.3 TCM TB (GENE EXPERT)": "MTBRIF", - "15.2.3 TCM TB (GENE EXPERT) GENE EXPERT": "MTBRIF", - "2.3.9 TCM GENE EXPERT": "MTBRIF", - "3.3.8 TCM GENE EXPERT": "MTBRIF", - "3.3.9 TCM MYCOBACTERIUM TUBERCULOSIS": "MTBRIF", - "5.3.9 TCM TBC (GENE EXPERT)": "MTBRIF", - "7.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "8.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "10.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", - "11.3.9 TCM TB (GENE EXPERT)": "MTBRIF", - -} -GENEXPERT_IP_CAPABILITIES = { - "10.10.120.75": ["MTBRIF", "HBVVL", "HIV1-VL", "MTB-XDR 2", "HCV", "SARSCOV2FLURSV"], - "10.10.120.73": ["MTBRIF", "HBVVL", "HIV1-VL", "HCV", "SARSCOV2FLURSV"], - "10.10.120.108": ["MTBRIF", "HCV", "HIV1-VL", "HBVVL", "SARSCOV2FLURSV"], -} -DEFAULT_GXP_CODE = "MTBRIF" - -DEVICE_CONFIGS = [ - { - 'port': 'COM6', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 1', - 'protocol': 'serial', 'flag_column': 'flg_vitek1' - }, - { - 'port': 'COM4', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 2', - 'protocol': 'serial', 'flag_column': 'flg_vitek2' - }, - { - 'port': 'COM5', 'baud_rate': 9600, 'device_type': 'bd', 'alat_name': 'BACTEC', - 'protocol': 'serial', 'flag_column': 'flg_bd1' - }, - #BD_MGIT yang di dalam ruangan isolasi - #{ - # 'port': 'COM4', 'baud_rate': 19200, 'device_type': 'bd', 'alat_name': 'MGIT', - # 'protocol': 'serial', 'flag_column': 'flg_bd2' - #}, -] -MYLA_HOST = '10.10.120.89' -MYLA_PORT = 8000 -MYLA_INBOUND_HOST = '0.0.0.0' -MYLA_INBOUND_PORT = 8000 -MYLA_POLL_INTERVAL_SECONDS = 1 -MYLA_CONNECT_RETRY_SECONDS = 5 -MYLA_CONTROL_TIMEOUT_SECONDS = 6 -MYLA_ACK_TIMEOUT_SECONDS = 8 -MYLA_IDLE_LOG_INTERVAL_SECONDS = 30 - -# Karakter kontrol standar -STX, ETX, ACK, NAK, EOT, ENQ = b'\x02', b'\x03', b'\x06', b'\x15', b'\x04', b'\x05' -RS, GS = b'\x1e', b'\x1d' -ports_lock = threading.Lock() -active_serial_ports = {} - -order_queues = {config['port']: Queue() for config in DEVICE_CONFIGS if config['protocol'] == 'serial'} - -# ========================================== -# 2. DATABASE MODEL -# ========================================== -DATABASE_URL = "postgresql://lismikro:lismikro@10.10.123.193:5002/lismikro" -engine = create_engine(DATABASE_URL, pool_recycle=3600) -SessionLocal = sessionmaker(bind=engine) -Base = declarative_base() - -class Sample(Base): - __tablename__ = 'samples' - id = Column(Integer, primary_key=True) - patient_name = Column(String(100)) - patient_id = Column(String(50)) - sample_id = Column(String(50), unique=True) - test_type = Column(String(100)) - result = Column(Text) - raw_message = Column(Text) - created_at = Column(SqDateTime, default=datetime.datetime.now) - -class SerialOrderQueue(Base): - __tablename__ = 'serial_order_queue' - id = Column(Integer, primary_key=True) - target_port = Column(String(50), nullable=False, index=True) - message_to_send = Column(Text, nullable=False) - status = Column(String(20), default='pending', index=True) - created_at = Column(SqDateTime, default=datetime.datetime.now) - updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) - -class LisPhoenix(Base): - __tablename__ = 'lis_phoenix' - id = Column(Integer, primary_key=True) - no_id = Column(String(50)) # Patient ID - seq_no = Column(String(50)) # Isolate ID / Sample ID - rnmpas = Column(String(100)) # Patient Name - tgl_data = Column(SqDate) - rawdt = Column(Text) - organisme = Column(String(100)) - kd_orgm = Column(String(50)) - alat = Column(String(50)) - processed = Column(String(50), nullable=True) - -class LisPhoenixDtl(Base): - __tablename__ = 'lis_phoenix_dtl' - id = Column(Integer, primary_key=True) - seq_no = Column(String(50)) - kd_antibiotik = Column(String(50)) - nm_antibiotik = Column(String(100)) - keterangan = Column(String(50)) - interpretasi = Column(String(10)) # S, I, R - no = Column(Integer) - -class PaslabOrder(Base): - __tablename__ = 'paslab' - urut = Column(Integer, primary_key=True) - rnoreg = Column(String) - nama = Column(String) - norm = Column(String) - rjenis = Column(String) - rtglast = Column(SqDateTime) - alamat = Column(String) - umur = Column(String) - namadok = Column(String) - ruangan = Column(String) - tes = Column(String) - alat = Column(String) - kd_spesimen = Column(String) - nm_spesimen = Column(String) - tgllahir = Column(SqDate) - flg_vitek1 = Column(Boolean, default=False) - flg_vitek2 = Column(Boolean, default=False) - flg_bd1 = Column(Boolean, default=False) - flg_bd2 = Column(Boolean, default=False) - flg_gxp1 = Column(Boolean, default=False) - flg_gxp2 = Column(Boolean, default=False) - flg_gxp3 = Column(Boolean, default=False) - flg_vitek3 = Column(Boolean, default=False) - created_at = Column(SqDateTime, default=datetime.datetime.now) - updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) - -Base.metadata.create_all(bind=engine) - -# ========================================== -# 3. HL7 HELPER FUNCTIONS -# ========================================== -def get_flag_by_device(ip_addr): - for flag, ip in TARGET_MAPPING.items(): - if ip == ip_addr: - return flag - return None - -def get_pending_orders(ip_addr): - flag = get_flag_by_device(ip_addr) - if not flag: - return [] - - session = SessionLocal() - try: - q = session.query(PaslabOrder).filter(getattr(PaslabOrder, flag) == False) - return q.all() - finally: - session.close() - -def parse_hl7_segments(hl7_message): - return [segment for segment in str(hl7_message or "").split('\r') if segment] - -def extract_segment(hl7_message, segment_name): - prefix = f"{segment_name}|" - for segment in parse_hl7_segments(hl7_message): - if segment.startswith(prefix): - return segment - return "" - -def parse_genexpert_qpd(qpd_segment): - fields = str(qpd_segment or "").split('|') - query_name = fields[1] if len(fields) > 1 else "" - query_tag = fields[2] if len(fields) > 2 else "" - param_1 = fields[3] if len(fields) > 3 else "" - param_2 = fields[4] if len(fields) > 4 else "" - return { - "query_name": query_name, - "query_tag": query_tag, - "param_1": param_1, - "param_2": param_2, - } - -def parse_astm_records(message_text): - records = [] - for rec in str(message_text or "").split("\r"): - rec = rec.strip() - if not rec: - continue - if rec and rec[0].isdigit(): - rec = rec[1:] - records.append(rec) - return records - -def parse_genexpert_astm_query(message_text): - records = parse_astm_records(message_text) - query = { - "query_sample_id": "", - "query_tag": "", - "raw_records": records, - } - for rec in records: - fields = rec.split("|") - if not fields: - continue - if fields[0] == "H": - query["query_tag"] = fields[4] if len(fields) > 4 else "" - elif fields[0] == "Q": - query["query_sample_id"] = fields[2] if len(fields) > 2 else "" - return query - -def summarize_genexpert_astm_orders(records): - summaries = [] - for rec in records: - if not rec.startswith("O|"): - continue - - fields = rec.split("|") - sample_id = fields[2].strip() if len(fields) > 2 else "" - container_id = fields[3].strip() if len(fields) > 3 else "" - assay_code = "" - if len(fields) > 4: - assay_parts = [part.strip() for part in fields[4].split("^") if part.strip()] - assay_code = assay_parts[-1] if assay_parts else fields[4].strip() - priority = fields[5].strip() if len(fields) > 5 else "" - action_code = fields[11].strip() if len(fields) > 11 else "" - - summaries.append( - f"sample_id={sample_id or '-'}, container_id={container_id or '-'}, " - f"assay={assay_code or '-'}, priority={priority or '-'}, action={action_code or '-'}" - ) - return summaries - -def detect_genexpert_message_framing(message_bytes): - raw = message_bytes or b"" - if b"\x1c" in raw or raw.startswith(b"\x0b"): - return "mllp" - # [PERBAIKAN KRITIS]: Harus mendeteksi ETX (\x03) ATAU ETB (\x17) - if b"\x03" in raw or b"\x17" in raw: - return "astm" - return "plain" - -def extract_astm_frame_text(frame_bytes): - raw = frame_bytes or b"" - if not raw.startswith(b"\x02"): # Jika tidak diawali STX, kosongkan - return "" - - etx_pos = raw.find(b"\x03") - etb_pos = raw.find(b"\x17") - - # Logika aman jika kebetulan ada dua-duanya di dalam buffer - if etx_pos != -1 and etb_pos != -1: - end_pos = min(etx_pos, etb_pos) - else: - end_pos = max(etx_pos, etb_pos) - - if end_pos == -1 or end_pos < 2: - return "" - - # Ambil mulai dari index ke-2 (mengabaikan STX dan Frame Number) - text_bytes = raw[2:end_pos] - return text_bytes.decode("latin-1", errors="ignore") - -def resolve_genexpert_assay(order, ip_addr=None): - assay_name = str(getattr(order, "tes", "") or "").strip() - specimen_code = str(getattr(order, "kd_spesimen", "") or "").strip() - assay_code = GENEXPERT_TEST_MAPPING.get(assay_name) - assay_source = "mapping:tes" - - if not assay_code and specimen_code: - assay_code = specimen_code - assay_source = "fallback:kd_spesimen" - - supported_codes = GENEXPERT_IP_CAPABILITIES.get(str(ip_addr or "").strip(), []) if ip_addr else [] - capability_match = True if not supported_codes else assay_code in supported_codes - - if not assay_code: - print( - f"[GENEXPERT-DEBUG] rnoreg={getattr(order, 'rnoreg', '')}, ip={ip_addr}, " - f"tes='{assay_name}', kd_spesimen='{specimen_code}', assay_code=EMPTY" - ) - return None, "none", capability_match - - print( - f"[GENEXPERT-DEBUG] rnoreg={getattr(order, 'rnoreg', '')}, ip={ip_addr}, " - f"tes='{assay_name}', kd_spesimen='{specimen_code}', assay_code='{assay_code}', " - f"assay_source={assay_source}, capability_match={capability_match}" - ) - return assay_code, assay_source, capability_match - -def get_genexpert_query_orders(ip_addr, hl7_msg): - flag = get_flag_by_device(ip_addr) - if not flag: - return [] - - qpd_segment = extract_segment(hl7_msg, "QPD") - qpd = parse_genexpert_qpd(qpd_segment) - param_1 = str(qpd.get("param_1") or "").strip() - param_2 = str(qpd.get("param_2") or "").strip() - - session = SessionLocal() - try: - flag_attr = getattr(PaslabOrder, flag, None) - if flag_attr is None: - return [] - - base_orders = session.query(PaslabOrder).filter( - (flag_attr == False) | (flag_attr == None) - ).order_by(PaslabOrder.urut.asc()).all() - - requested_sample_id = (param_2 or param_1).strip() if (param_2 or param_1) else "" - if requested_sample_id and requested_sample_id.upper() != "ALL": - for order in base_orders: - if str(order.rnoreg or "").strip() != requested_sample_id: - continue - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - return [] - return [order] - print(f"[GENEXPERT] Tidak ada order untuk sample_id={requested_sample_id} di {ip_addr}") - return [] - - for order in base_orders: - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if assay_code: - return [order] - print(f"[GENEXPERT] Tidak ada order dengan assay valid untuk IP {ip_addr}") - return [] - finally: - session.close() - -def get_genexpert_host_application(ip_addr): - ip_addr = str(ip_addr or "").strip() - host_app = GENEXPERT_HOST_APPLICATION_BY_IP.get(ip_addr, GENEXPERT_HOST_APPLICATION_DEFAULT) - host_app = str(host_app or "").strip() - return host_app or GENEXPERT_HOST_APPLICATION_DEFAULT - -def build_genexpert_response_msh(message_code, incoming_hl7, resp_control_id, ip_addr=None): - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - msh_fields = extract_segment(incoming_hl7, "MSH").split('|') - sender_app = msh_fields[2] if len(msh_fields) > 2 else "GeneXpert" - sender_fac = msh_fields[3] if len(msh_fields) > 3 else "" - host_app = get_genexpert_host_application(ip_addr) - return f"MSH|^~\\&|{host_app}||{sender_app}|{sender_fac}|{timestamp}||{message_code}|{resp_control_id}|P|2.5|||NE|NE" - -def create_genexpert_ack_j01_response(incoming_hl7, ip_addr=None): - incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" - resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - msh = build_genexpert_response_msh("ACK^J01", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|CA|{incoming_control_id}" - return f"{msh}\r{msa}\r" - -def create_genexpert_ack_r01_response(incoming_hl7, ip_addr=None): - incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" - resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - msh = build_genexpert_response_msh("ACK^R01", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|CA|{incoming_control_id}" - return f"{msh}\r{msa}\r" - -def format_hl7_date(value): - if not value: - return "" - if isinstance(value, datetime.datetime): - return value.strftime("%Y%m%d") - if isinstance(value, datetime.date): - return value.strftime("%Y%m%d") - text = str(value).strip() - digits = re.sub(r"[^0-9]", "", text) - return digits[:8] if len(digits) >= 8 else "" - -def map_hl7_sex(value): - text = str(value or "").strip().upper() - if not text: - return "" - if text.startswith("L") or "LAKI" in text or text == "M": - return "M" - if text.startswith("P") or "PEREM" in text or text == "F": - return "F" - return "" - -def create_genexpert_rsp_z02_response(orders, incoming_hl7, ip_addr=None): - qpd_segment = extract_segment(incoming_hl7, "QPD") - qpd = parse_genexpert_qpd(qpd_segment) - query_tag = qpd.get("query_tag") or (extract_msg_control_id(incoming_hl7) or "UNKNOWN") - incoming_message_type = extract_message_type(incoming_hl7) - query_name = qpd.get("query_name") or "Z03^HOST QUERY" - resp_control_id = f"RSP{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - - if str(incoming_message_type or "").startswith("QBP^Z03"): - query_name = "Z03^HOST QUERY" - - msh = build_genexpert_response_msh("RSP^Z02", incoming_hl7, resp_control_id, ip_addr=ip_addr) - msa = f"MSA|AA|{query_tag}" - qak = f"QAK|{query_tag}|OK|{query_name}" - segments = [msh, msa, qak] - if str(incoming_message_type or "").startswith("QBP^Z03"): - selected_patient_id = "" - selected_sample_id = "" - if orders: - selected_patient_id = sanitize_astm_field(orders[0].norm or "", max_len=50) - selected_sample_id = sanitize_astm_field(orders[0].rnoreg or "", max_len=50) - segments.append(f"QPD|{query_name}|{query_tag}|{selected_patient_id}|{selected_sample_id}") - elif qpd_segment: - segments.append(qpd_segment) - - for patient_idx, order in enumerate(orders, start=1): - patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=50) - sample_id = sanitize_astm_field(order.rnoreg, max_len=50) - order_ts = ( - getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') - if getattr(order, "rtglast", None) - else datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ) - assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - print(f"[GENEXPERT] Payload order dilewati rnoreg={sample_id} karena assay kosong.") - continue - - print( - f"[GENEXPERT-DEBUG] Build RSP rnoreg={sample_id}, ip={ip_addr}, " - f"patient_id={patient_id}, assay_code={assay_code}, assay_source={assay_source}, " - f"capability_match={capability_match}, query_name='{query_name}', query_tag='{query_tag}', " - "profile='minimal-rsp-z02'" - ) - - segments.append(f"PID|{patient_idx}||{patient_id}") - segments.append(f"ORC|NW|1|||||||{order_ts}") - segments.append(f"OBR|1|||{assay_code}|||||||A") - segments.append("TQ1|||||||||R") - segments.append(f"SPM|1|{sample_id}^||ORH|||||||P") - - return "\r".join(segments) + "\r" - -def create_genexpert_astm_order_message(orders, ip_addr=None, query_tag=""): - timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") - records = [ - f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{timestamp}" - ] - - for index, order in enumerate(orders, start=1): - patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=32) - sample_id = sanitize_astm_field(order.rnoreg, max_len=32) - assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) - if not assay_code: - print(f"[GENEXPERT] ASTM payload order dilewati rnoreg={sample_id} karena assay kosong.") - continue - - first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) - first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) - last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) - patient_name = f"{last_name}^{first_name}".strip("^") - sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) - sex = "M" if sex_raw.startswith("L") else ("F" if sex_raw else "") - order_ts = ( - getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') - if getattr(order, "rtglast", None) - else timestamp - ) - - print( - f"[GENEXPERT-ASTM-ORDER] rnoreg={sample_id}, ip={ip_addr}, patient_id={patient_id}, " - f"assay_code={assay_code}, assay_source={assay_source}, capability_match={capability_match}" - ) - - records.append(f"P|{index}|{patient_name}||{patient_id}|{patient_name}|||{sex}") - records.append(f"O|1|{sample_id}||^^^{assay_code}|R|{order_ts}|||||||||ORH||||||||||A") - - records.append("L|1|N") - message = "\r".join(records) + "\r" - print(f"[GENEXPERT-ASTM-ORDER] ip={ip_addr}, query_tag={query_tag}, records={len(records)}, visible={_visible_bytes(message.encode('latin-1'))}") - return message - -def send_all_orders(conn, ip_addr, hl7_msg, msg_id, response_framing="mllp"): - orders = get_genexpert_query_orders(ip_addr, hl7_msg) - scheduled_orders = [] - if not orders: - print(f"[GENEXPERT] Tidak ada order pending untuk {ip_addr}") - rsp = create_genexpert_rsp_z02_response([], hl7_msg, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, rsp, response_framing, label="qbp-empty") - return - - qpd_segment = extract_segment(hl7_msg, "QPD") - print( - f"[GENEXPERT-DEBUG] QBP diproses untuk ip={ip_addr}, msg_id={msg_id}, " - f"qpd='{qpd_segment}', selected_rnoreg={[str(order.rnoreg or '').strip() for order in orders]}" - ) - print(f"[GENEXPERT] Mengirim {len(orders)} order ke {ip_addr}") - rsp = create_genexpert_rsp_z02_response(orders, hl7_msg, ip_addr=ip_addr) - first_accnumber = str(orders[0].rnoreg or "").strip() if orders else "" - debug_genexpert_order_message(rsp, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, rsp, response_framing, label=f"qbp-order:{first_accnumber}") - - for order in orders: - print(f"[GENEXPERT] Order ditawarkan ke {ip_addr}: {order.rnoreg}") - scheduled_orders.append({ - "accnumber": str(order.rnoreg or "").strip(), - "register_no": str(order.rnoreg or "").strip(), - "target_ip": ip_addr, - }) - - for scheduled_order in scheduled_orders: - schedule_result_query_for_order( - accnumber=scheduled_order["accnumber"], - register_no=scheduled_order["register_no"], - target_ip=scheduled_order["target_ip"], - ) - -def send_all_orders_astm(conn, ip_addr, astm_msg, response_framing="astm"): - query = parse_genexpert_astm_query(astm_msg) - requested_sample_id = str(query.get("query_sample_id") or "").strip() - query_tag = str(query.get("query_tag") or "").strip() - flag = get_flag_by_device(ip_addr) - if not flag: - print(f"[GENEXPERT] ASTM query diabaikan, flag untuk {ip_addr} tidak ditemukan.") - return - - session = SessionLocal() - try: - flag_attr = getattr(PaslabOrder, flag, None) - if flag_attr is None: - print(f"[GENEXPERT] ASTM query diabaikan, atribut flag {flag} tidak ada.") - return - - base_orders = session.query(PaslabOrder).filter( - (flag_attr == False) | (flag_attr == None) - ).order_by(PaslabOrder.urut.asc()).all() - - selected_orders = [] - if requested_sample_id and requested_sample_id.upper() != "ALL": - for order in base_orders: - if str(order.rnoreg or "").strip() != requested_sample_id: - continue - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if assay_code: - selected_orders = [order] - break - else: - for order in base_orders: - assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) - if assay_code: - selected_orders = [order] - break - - print( - f"[GENEXPERT-ASTM-QUERY] ip={ip_addr}, query_tag={query_tag}, requested_sample_id='{requested_sample_id}', " - f"selected_rnoreg={[str(order.rnoreg or '').strip() for order in selected_orders]}" - ) - - if not selected_orders: - reply = f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}\rL|1|N\r" - send_genexpert_response(conn, ip_addr, reply, response_framing, label="astm-q-empty") - return - - reply = create_genexpert_astm_order_message(selected_orders, ip_addr=ip_addr, query_tag=query_tag) - send_genexpert_response(conn, ip_addr, reply, response_framing, label=f"astm-q-order:{selected_orders[0].rnoreg}") - for order in selected_orders: - print(f"[GENEXPERT] Order ASTM ditawarkan ke {ip_addr}: {order.rnoreg}") - finally: - session.close() - -def extract_msg_control_id(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 9: - return msh[9].strip() - return None - except: - return None - -def extract_message_type(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 8: - return msh[8].strip() - return "" - except: - return "" - -def build_hl7_preview(hl7_message, max_segments=4): - try: - segments = [segment.strip() for segment in str(hl7_message or "").split('\r') if segment.strip()] - preview = " | ".join(segments[:max_segments]) - return preview[:800] - except Exception: - return str(hl7_message or "")[:800] - -def log_genexpert_hl7(direction, ip_addr, hl7_message, label=""): - message_type = extract_message_type(hl7_message) or "UNKNOWN" - control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" - suffix = f", label={label}" if label else "" - preview = build_hl7_preview(hl7_message) - logging.info( - f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" - ) - print( - f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" - ) - -def log_genexpert_hl7_full(direction, ip_addr, hl7_message, label=""): - message_type = extract_message_type(hl7_message) or "UNKNOWN" - control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" - suffix = f", label={label}" if label else "" - payload = str(hl7_message or "").replace("\r", "\\r\n") - logging.info( - f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" - ) - print( - f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" - ) - -def frame_genexpert_response(hl7_message, framing): - message = str(hl7_message or "") - if framing == "astm": - frame_body = f"1{message}\x03" - chk = calculate_astm_checksum(frame_body) - return f"\x02{frame_body}{chk}\r\n".encode("latin-1") - if framing == "mllp": - return f"\x0b{message}\x1c\r".encode("utf-8") - return message.encode("utf-8") - -def build_genexpert_astm_frames(hl7_message, max_text_bytes=240): - message = str(hl7_message or "") - text_bytes = message.encode("latin-1") - chunks = [text_bytes[i:i + max_text_bytes] for i in range(0, len(text_bytes), max_text_bytes)] or [b""] - frames = [] - frame_number = 1 - - for index, chunk in enumerate(chunks): - is_last = index == len(chunks) - 1 - terminator = b"\x03" if is_last else b"\x17" - frame_no_byte = str(frame_number).encode("ascii") - frame_core_bytes = frame_no_byte + chunk + terminator - checksum = calculate_astm_checksum(frame_core_bytes.decode("latin-1", errors="ignore")).encode("ascii") - full_frame = b"\x02" + frame_core_bytes + checksum + b"\x0D\x0A" - frames.append({ - "frame_number": frame_number, - "is_last": is_last, - "chunk_len": len(chunk), - "payload": full_frame, - "checksum": checksum.decode("ascii", errors="ignore"), - }) - frame_number = (frame_number + 1) % 8 - return frames - -def recv_genexpert_control_char(conn, timeout_seconds=5): - previous_timeout = conn.gettimeout() - try: - conn.settimeout(timeout_seconds) - return conn.recv(1) - finally: - conn.settimeout(previous_timeout) - -def send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=""): - try: - frames = build_genexpert_astm_frames(hl7_message) - print(f"[GENEXPERT-ASTM-TX] ip={ip_addr}, label={label}, total_frames={len(frames)}, mode=astm_active") - conn.sendall(b"\x05") - log_genexpert_handshake(ip_addr, "ENQ-TX", detail=f"label={label}") - - ctrl = recv_genexpert_control_char(conn, timeout_seconds=5) - if ctrl == b"\x06": - log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=pre-frame,label={label}") - elif ctrl == b"\x15": - log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=pre-frame,label={label}") - return False - else: - log_genexpert_handshake(ip_addr, "CTRL-RX", detail=f"phase=pre-frame,label={label},hex={ctrl.hex() if ctrl else 'timeout'}") - return False - - for frame in frames: - payload = frame["payload"] - debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=f"{label}:frame{frame['frame_number']}") - conn.sendall(payload) - log_genexpert_handshake( - ip_addr, - "FRAME-TX", - detail=( - f"label={label},frame_no={frame['frame_number']},bytes={len(payload)}," - f"chunk_len={frame['chunk_len']},last={frame['is_last']}" - ), - ) - - ctrl = recv_genexpert_control_char(conn, timeout_seconds=15) - if ctrl == b"\x06": - log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - continue - if ctrl == b"\x15": - log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=nak,frame_no={frame['frame_number']}") - return False - if ctrl == b"\x04": - log_genexpert_handshake(ip_addr, "EOT-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after-peer-eot,frame_no={frame['frame_number']}") - return False - - log_genexpert_handshake( - ip_addr, - "CTRL-RX", - detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']},hex={ctrl.hex() if ctrl else 'timeout'}", - ) - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=unexpected,frame_no={frame['frame_number']}") - return False - - conn.sendall(b"\x04") - log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label}") - return True - except Exception as exc: - log_genexpert_handshake(ip_addr, "ASTM-SEND-ERROR", detail=f"label={label},error={exc}") - return False - -def get_genexpert_response_mode(ip_addr): - ip_addr = str(ip_addr or "").strip() - mode = GENEXPERT_RESPONSE_MODE_BY_IP.get(ip_addr, GENEXPERT_RESPONSE_MODE_DEFAULT) - if mode not in {"hl7_passive", "astm_active"}: - mode = GENEXPERT_RESPONSE_MODE_DEFAULT - return mode - -def send_genexpert_response(conn, ip_addr, hl7_message, framing, label=""): - log_genexpert_hl7("OUT", ip_addr, hl7_message, label=label) - log_genexpert_hl7_full("OUT", ip_addr, hl7_message, label=label) - response_mode = get_genexpert_response_mode(ip_addr) - if framing == "astm" and response_mode == "astm_active": - print( - f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " - f"response_mode={response_mode}, label={label}, bytes=multi-frame" - ) - send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=label) - return - - payload = frame_genexpert_response(hl7_message, framing) - if framing == "astm": - debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=label) - else: - print( - f"[GENEXPERT-FRAME-TX] ip={ip_addr}, label={label}, framing={framing}, " - f"hex={_hex_bytes(payload)}, visible={_visible_bytes(payload)}" - ) - print( - f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " - f"response_mode={response_mode}, label={label}, bytes={len(payload)}" - ) - conn.sendall(payload) - -def send_genexpert_transport_ack(conn, ip_addr, framing, reason="frame-received"): - if framing != "astm": - return - try: - conn.sendall(b"\x06") - print(f"[GENEXPERT-DEBUG] Transport ACK sent ip={ip_addr}, framing={framing}, reason={reason}") - except Exception as exc: - print(f"[GENEXPERT-DEBUG] Transport ACK gagal ip={ip_addr}, framing={framing}, reason={reason}, error={exc}") - -def log_genexpert_handshake(ip_addr, event, detail=""): - suffix = f", detail={detail}" if detail else "" - print(f"[GENEXPERT-HANDSHAKE] ip={ip_addr}, event={event}{suffix}") - -def debug_genexpert_astm_frame(ip_addr, frame_bytes, direction="RX", label=""): - raw = frame_bytes or b"" - suffix = f", label={label}" if label else "" - if not raw: - print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, empty-frame") - return - - detail_parts = [ - f"len={len(raw)}", - f"hex={_hex_bytes(raw)}", - f"visible={_visible_bytes(raw)}", - ] - - if raw.startswith(b"\x02") and len(raw) >= 6: - frame_no = raw[1:2] - etx_pos = raw.find(b"\x03") - etb_pos = raw.find(b"\x17") - end_pos = etx_pos if etx_pos != -1 else etb_pos - end_name = "ETX" if etx_pos != -1 else ("ETB" if etb_pos != -1 else "NONE") - if end_pos != -1 and len(raw) >= end_pos + 5: - checksum_rx = raw[end_pos + 1:end_pos + 3] - trailer = raw[end_pos + 3:end_pos + 5] - checksum_basis = raw[1:end_pos + 1].decode("latin-1", errors="ignore") - checksum_calc = calculate_astm_checksum(checksum_basis).encode("ascii") - checksum_ok = checksum_rx.upper() == checksum_calc.upper() - trailer_ok = trailer == b"\r\n" - detail_parts.extend([ - f"frame_no={frame_no.decode('ascii', errors='ignore')}", - f"terminator=<{end_name}>", - f"checksum_rx={checksum_rx.decode('ascii', errors='ignore')}", - f"checksum_calc={checksum_calc.decode('ascii', errors='ignore')}", - f"checksum_ok={checksum_ok}", - f"trailer={_visible_bytes(trailer)}", - f"trailer_ok={trailer_ok}", - ]) - else: - detail_parts.append(f"terminator=<{end_name}>") - - print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, " + ", ".join(detail_parts)) - -def debug_genexpert_order_message(hl7_message, ip_addr=None): - segments = parse_hl7_segments(hl7_message) - current_pid = "" - current_patient_name = "" - order_index = 0 - - for segment in segments: - fields = segment.split("|") - seg_type = fields[0] if fields else "" - - if seg_type == "PID": - current_pid = fields[3] if len(fields) > 3 else "" - current_patient_name = fields[5] if len(fields) > 5 else "" - dob = fields[7] if len(fields) > 7 else "" - sex = fields[8] if len(fields) > 8 else "" - address = fields[11] if len(fields) > 11 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=PID, patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', dob='{dob}', sex='{sex}', " - f"address='{address}', mode='minimal', raw='{segment}'" - ) - elif seg_type == "ORC": - order_index = fields[2] if len(fields) > 2 else "" - order_time = fields[9] if len(fields) > 9 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=ORC, placer_order='{order_index}', " - f"order_time='{order_time}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "OBR": - assay_code = fields[4] if len(fields) > 4 else "" - result_status = fields[11] if len(fields) > 11 else "" - assay_parts = assay_code.split("^") - assay_id = assay_parts[0] if len(assay_parts) > 0 else "" - assay_name = assay_parts[1] if len(assay_parts) > 1 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=OBR, placer_order='{order_index}', " - f"assay_code='{assay_id}', assay_name='{assay_name}', result_status='{result_status}', " - f"patient_id='{current_pid}', patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "TQ1": - priority = fields[9] if len(fields) > 9 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=TQ1, placer_order='{order_index}', " - f"priority='{priority}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - elif seg_type == "SPM": - specimen_id = fields[2] if len(fields) > 2 else "" - specimen_type = fields[4] if len(fields) > 4 else "" - print( - f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=SPM, placer_order='{order_index}', " - f"specimen_id='{specimen_id}', specimen_type='{specimen_type}', patient_id='{current_pid}', " - f"patient_name='{current_patient_name}', raw='{segment}'" - ) - -def build_genexpert_result_query(accnumber, msg_control_id): - ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - # Query hasil berbasis accession number di QRD-8. - query_msg = ( - f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ts}||QRY^Q02|{msg_control_id}|P|2.5\r" - f"QRD|{ts}|R|I|{msg_control_id}|||1^RD|{accnumber}|OTH|||T\r" - ) - return query_msg - -def get_active_genexpert_ips(): - with connection_lock: - return list(active_genexpert_connections.keys()) - -def clear_genexpert_inflight_for_ip(ip_addr, reason="cleared"): - ip_addr = str(ip_addr or "").strip() - if not ip_addr: - return False - - with genexpert_query_inflight_lock: - state = genexpert_query_inflight_by_ip.pop(ip_addr, None) - - if state: - logging.info( - f"[GENEXPERT-QUERY] Clear inflight ip={ip_addr}, reason={reason}, accnumber={state.get('accnumber')}" - ) - print( - f"[GENEXPERT-QUERY] Clear inflight ip={ip_addr}, reason={reason}, accnumber={state.get('accnumber')}" - ) - return True - return False - -def stop_all_scheduled_result_queries(reason="no-active-genexpert"): - with scheduled_result_query_lock: - if not scheduled_result_queries: - return 0 - - stopped_accnumbers = list(scheduled_result_queries.keys()) - for accnumber in stopped_accnumbers: - state = scheduled_result_queries.get(accnumber) - if not state: - continue - state["status"] = reason - stop_event = state.get("stop_event") - if stop_event: - stop_event.set() - - scheduled_result_queries.clear() - - logging.info( - f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" - ) - print( - f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" - ) - with genexpert_query_inflight_lock: - genexpert_query_inflight_by_ip.clear() - return len(stopped_accnumbers) - -def stop_scheduled_result_query(accnumber, reason="completed"): - accnumber = str(accnumber or "").strip() - if not accnumber: - return False - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return False - state["status"] = reason - stop_event = state.get("stop_event") - if stop_event: - stop_event.set() - scheduled_result_queries.pop(accnumber, None) - - logging.info(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") - print(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") - return True - -def scheduled_result_query_worker(accnumber): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - stop_scheduled_result_query(accnumber, reason="disabled") - return - - while True: - if not get_active_genexpert_ips(): - stop_all_scheduled_result_queries(reason="no-active-genexpert") - return - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - stop_event = state["stop_event"] - target_ip = state.get("target_ip") - register_no = state.get("register_no") or accnumber - attempt = int(state.get("attempt", 0)) - created_at = state.get("created_at") or datetime.datetime.now() - max_duration_seconds = max( - int(state.get("max_duration_seconds", GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS)), - 1 - ) - age_seconds = max(int((datetime.datetime.now() - created_at).total_seconds()), 0) - if age_seconds >= max_duration_seconds: - state["status"] = "expired" - state["expired_at"] = datetime.datetime.now() - state["age_seconds"] = age_seconds - stop_event.set() - scheduled_result_queries.pop(accnumber, None) - logging.info( - f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " - f"age={age_seconds}s, max_duration={max_duration_seconds}s" - ) - print( - f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " - f"age={age_seconds}s, max_duration={max_duration_seconds}s" - ) - return - next_delay = max( - int( - state.get( - "initial_delay_seconds" if attempt == 0 else "interval_seconds", - GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS if attempt == 0 else GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS - ) - ), - 1 - ) - - if stop_event.wait(next_delay): - return - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - state["attempt"] = int(state.get("attempt", 0)) + 1 - state["last_requested_at"] = datetime.datetime.now() - attempt_no = state["attempt"] - - print( - f"[GENEXPERT-SCHEDULER] Trigger query hasil accnumber={accnumber}, " - f"register_no={register_no}, target_ip={target_ip}, attempt={attempt_no}" - ) - - result = trigger_result_query_to_genexpert( - accnumber=accnumber, - register_no=register_no, - target_ip=target_ip, - wait_seconds=0, - ) - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - state["last_result"] = result - if result.get("ok"): - state["last_successful_query_at"] = datetime.datetime.now() - else: - state["last_error"] = result.get("message") - -def schedule_result_query_for_order( - accnumber, - register_no, - target_ip=None, - initial_delay_seconds=GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS, - interval_seconds=GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS, - max_duration_seconds=GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS, -): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - print( - f"[GENEXPERT-SCHEDULER] Nonaktif. Jadwal query hasil dilewati untuk accnumber={accnumber}" - ) - return False - - accnumber = str(accnumber or "").strip() - register_no = str(register_no or accnumber).strip() - target_ip = str(target_ip or "").strip() or None - - if not accnumber: - print("[GENEXPERT-SCHEDULER] Jadwal query hasil dilewati karena accnumber kosong.") - return False - - with scheduled_result_query_lock: - existing = scheduled_result_queries.get(accnumber) - if existing: - existing["register_no"] = register_no - existing["target_ip"] = target_ip - existing["initial_delay_seconds"] = initial_delay_seconds - existing["interval_seconds"] = interval_seconds - existing["max_duration_seconds"] = max_duration_seconds - existing["status"] = "active" - print(f"[GENEXPERT-SCHEDULER] Jadwal sudah aktif untuk accnumber={accnumber}") - return True - - stop_event = threading.Event() - worker = threading.Thread( - target=scheduled_result_query_worker, - args=(accnumber,), - name=f"GeneXpertResultQuery-{accnumber}", - daemon=True, - ) - scheduled_result_queries[accnumber] = { - "register_no": register_no, - "target_ip": target_ip, - "initial_delay_seconds": initial_delay_seconds, - "interval_seconds": interval_seconds, - "max_duration_seconds": max_duration_seconds, - "attempt": 0, - "status": "active", - "created_at": datetime.datetime.now(), - "stop_event": stop_event, - "thread_name": worker.name, - } - - worker.start() - print( - f"[GENEXPERT-SCHEDULER] Jadwal dibuat accnumber={accnumber}, " - f"register_no={register_no}, target_ip={target_ip}, " - f"initial_delay={initial_delay_seconds}s, interval={interval_seconds}s, " - f"max_duration={max_duration_seconds}s" - ) - return True - -def trigger_result_query_to_genexpert(accnumber, register_no, target_ip=None, wait_seconds=20): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - return { - "ok": False, - "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", - } - - active_ips = get_active_genexpert_ips() - if not active_ips: - return {"ok": False, "message": "Tidak ada koneksi GeneXpert aktif."} - - if target_ip: - target_ips = [target_ip] if target_ip in active_ips else [] - if not target_ips: - return {"ok": False, "message": f"Koneksi GeneXpert {target_ip} tidak aktif."} - else: - target_ips = active_ips - - ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - msg_control_id = f"LISQRY{ts}{int(time.time() * 1000) % 1000:03d}" - query_message = build_genexpert_result_query(accnumber, msg_control_id) - mllp_payload = f"\x0b{query_message}\x1c\r".encode("utf-8") - - pending_event = threading.Event() - with pending_query_lock: - pending_result_queries[accnumber] = { - "register_no": register_no, - "target_ips": list(target_ips), - "msg_control_id": msg_control_id, - "requested_at": datetime.datetime.now(), - "event": pending_event, - "status": "requested", - } - - sent_ips = [] - failed_ips = [] - for ip in target_ips: - now = datetime.datetime.now() - with genexpert_query_inflight_lock: - inflight = genexpert_query_inflight_by_ip.get(ip) - if inflight: - inflight_age = max( - (now - inflight.get("requested_at", now)).total_seconds(), - 0 - ) - if inflight_age < GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS: - failed_ips.append({ - "ip": ip, - "error": f"query-inflight:{inflight.get('accnumber')}", - }) - print( - f"[GENEXPERT-QUERY] Skip query accnumber={accnumber} ke {ip} " - f"karena masih menunggu accnumber={inflight.get('accnumber')} " - f"({int(inflight_age)}s)" - ) - continue - genexpert_query_inflight_by_ip.pop(ip, None) - - with connection_lock: - conn = active_genexpert_connections.get(ip) - if not conn: - failed_ips.append({"ip": ip, "error": "connection-not-active"}) - continue - try: - log_genexpert_hl7("OUT", ip, query_message, label=f"result-query:{accnumber}") - log_genexpert_hl7_full("OUT", ip, query_message, label=f"result-query:{accnumber}") - conn.sendall(mllp_payload) - with genexpert_query_inflight_lock: - genexpert_query_inflight_by_ip[ip] = { - "accnumber": accnumber, - "register_no": register_no, - "requested_at": now, - "msg_control_id": msg_control_id, - } - sent_ips.append(ip) - print(f"[GENEXPERT-QUERY] Kirim query hasil accnumber={accnumber} ke {ip}") - except Exception as e: - failed_ips.append({"ip": ip, "error": str(e)}) - clear_genexpert_inflight_for_ip(ip, reason="send-failed") - - if not sent_ips: - with pending_query_lock: - pending_result_queries.pop(accnumber, None) - return {"ok": False, "message": "Gagal kirim query ke semua GeneXpert aktif.", "failures": failed_ips} - - if wait_seconds and wait_seconds > 0: - pending_event.wait(wait_seconds) - with pending_query_lock: - state = pending_result_queries.get(accnumber) - if state and state.get("status") == "found": - pending_result_queries.pop(accnumber, None) - return { - "ok": True, - "message": "Hasil ditemukan dan disimpan ke LisPhoenix.", - "target_ips": sent_ips, - "accnumber": accnumber, - "source_ip": state.get("source_ip"), - } - # Timeout/hasil belum masuk, biarkan state tetap ada agar response telat tetap bisa diproses. - return { - "ok": True, - "message": "Query terkirim. Menunggu hasil dari GeneXpert.", - "target_ips": sent_ips, - "accnumber": accnumber, - "failures": failed_ips, - } - - return { - "ok": True, - "message": "Query terkirim.", - "target_ips": sent_ips, - "accnumber": accnumber, - "failures": failed_ips, - } - -@app.route("/api/genexpert/query-result", methods=["POST"]) -def api_query_genexpert_result(): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - return jsonify({ - "ok": False, - "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", - }), 409 - - payload = request.get_json(silent=True) or {} - accnumber = str(payload.get("accnumber") or "").strip() - register_no = str(payload.get("register_no") or payload.get("nomor_register") or "").strip() - target_ip = str(payload.get("target_ip") or "").strip() or None - - try: - wait_seconds = int(payload.get("wait_seconds", 20)) - except Exception: - wait_seconds = 20 - - if not accnumber: - return jsonify({"ok": False, "message": "Field 'accnumber' wajib diisi."}), 400 - if not register_no: - return jsonify({"ok": False, "message": "Field 'register_no' (nomor register pasien) wajib diisi."}), 400 - - result = trigger_result_query_to_genexpert( - accnumber=accnumber, - register_no=register_no, - target_ip=target_ip, - wait_seconds=wait_seconds, - ) - status_code = 200 if result.get("ok") else 409 - return jsonify(result), status_code - -def parse_hl7_result(conn, msg_id, hl7_message, device_name="GeneXpert"): - session = SessionLocal() - clean_hl7 = hl7_message - try: - # 1. Bersihkan dan Split Pesan - # HL7 dipisahkan oleh \r (Carriage Return) - if "MSH|" not in hl7_message: - logging.warning("[HL7] Data tidak mengandung segmen MSH valid.") - print("[HL7] Data tidak mengandung segmen MSH valid.") - return - start_idx = hl7_message.find("MSH|") - hl7_message = hl7_message[start_idx:] - - segments = hl7_message.strip().split('\r') - - # Variabel penampung - sample_id = "" # no_id - patient_id = "" # seq_no - patient_name = "" # rnmpas - result_date = None # tgl_data - results_list = [] # untuk organisme - message_type = "" - - # Waktu default jika tidak ada di pesan - result_date = datetime.datetime.now() - - # 2. Loop setiap segmen - for segment in segments: - fields = segment.split('|') - if not fields: continue - - seg_type = fields[0] - - # --- MSH (Header) --- - if seg_type == 'MSH': - if len(fields) > 8 and fields[8]: - message_type = fields[8].strip() - # Ambil tanggal pesan (Field 7) Format: YYYYMMDDHHMMSS - if len(fields) > 6 and fields[6]: - try: - raw_date = fields[6][:14] # Ambil 14 digit pertama - result_date = datetime.datetime.strptime(raw_date, "%Y%m%d%H%M%S") - except ValueError: - pass # Gunakan default datetime.now() jika format salah - - # --- PID (Patient ID) --- - elif seg_type == 'PID': - # PID|3 = Patient ID (seq_no) - if len(fields) > 3: - patient_id = fields[3].replace('^', '') - - # PID|5 = Patient Name (rnmpas) - if len(fields) > 5: - # Ganti caret ^ dengan spasi (Family^Name -> Family Name) - patient_name = fields[5].replace('^', ' ').strip() - - # --- OBR (Observation Request - Info Sample) --- - elif seg_type == 'OBR': - # OBR|2 atau OBR|3 biasanya berisi Sample ID / Accession No - # Kita coba ambil field 3 (Filler Order Number) dulu, kalau kosong field 2 - if len(fields) > 3 and fields[3]: - sample_id = fields[3].replace('^', '') - elif len(fields) > 2 and fields[2]: - sample_id = fields[2].replace('^', '') - - # --- OBX (Observation Result - Hasil Tes) --- - elif seg_type == 'OBX': - # OBX|3 = Test Name/Code (Misal: MTB, RIF) - # OBX|5 = Result Value (Misal: DETECTED, NOT DETECTED) - if len(fields) > 5: - test_name = fields[3].split('^')[1] if '^' in fields[3] else fields[3] - test_val = fields[5].replace('^', ' ') - - # Gabungkan nama tes dan hasil - # Contoh: "MTB: DETECTED" - results_list.append(f"{test_name}: {test_val}") - - # 3. Gabungkan semua hasil OBX menjadi satu string - if not results_list: - final_result = "No Result Found" - else: - final_result = "; ".join(results_list) - - # 4. Validasi Data Penting - if not sample_id: - if str(message_type).startswith("ORU^"): - source_ip = "" - try: - source_ip = str(conn.getpeername()[0]).strip() - except Exception: - source_ip = str(device_name).replace("GeneXpert-", "").replace(",", " ").strip() - - if source_ip: - logging.info(f"[HL7 Parser] ORU tanpa sample_id dari {source_ip}. Anggap sebagai request order.") - print(f"[HL7 Parser] ORU tanpa sample_id dari {source_ip}. Anggap sebagai request order.") - send_all_orders(conn, source_ip, clean_hl7, msg_id) - else: - logging.warning("[HL7 Parser] ORU tanpa sample_id diterima, tetapi IP sumber tidak dapat diidentifikasi.") - print("[HL7 Parser] ORU tanpa sample_id diterima, tetapi IP sumber tidak dapat diidentifikasi.") - else: - logging.info(f"[HL7 Parser] Pesan {message_type or 'UNKNOWN'} tanpa sample_id diabaikan.") - print(f"[HL7 Parser] Pesan {message_type or 'UNKNOWN'} tanpa sample_id diabaikan.") - return - - mapped_no_id = sample_id - mapped_seq_no = patient_id - mapped_alat = device_name - pending_event = None - - with pending_query_lock: - pending = pending_result_queries.get(sample_id) - if pending: - source_ip = str(device_name).replace("GeneXpert-", "").strip() - mapped_no_id = pending.get("register_no") or sample_id - mapped_seq_no = sample_id - mapped_alat = f"GeneXpert-{source_ip}" if source_ip else device_name - pending["status"] = "found" - pending["source_ip"] = source_ip - pending["response_at"] = datetime.datetime.now() - pending_event = pending.get("event") - clear_genexpert_inflight_for_ip(source_ip, reason="result-received") - - source_ip = "" - try: - source_ip = str(conn.getpeername()[0]).strip() - except Exception: - source_ip = str(device_name).replace("GeneXpert-", "").replace(",", " ").strip() - - if source_ip: - flag_name = get_flag_by_device(source_ip) - if flag_name: - order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == sample_id).first() - if order: - setattr(order, flag_name, True) - print(f"[GENEXPERT] Hasil diterima, set {flag_name}=TRUE untuk {sample_id}") - - print(f"[HL7 Parser] Menyimpan hasil untuk Sample: {sample_id}") - - # 5. Simpan ke Database - new_data = LisPhoenix( - no_id=mapped_no_id, - seq_no=mapped_seq_no, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=hl7_message, - organisme=final_result, - alat=mapped_alat - ) - - session.add(new_data) - session.commit() - print(f"[DB] Berhasil simpan ke LisPhoenix: {sample_id}") - stop_scheduled_result_query(sample_id, reason="result-received") - if pending_event: - pending_event.set() - - except Exception as e: - logging.error(f"[HL7 Parser] Error menyimpan data: {e}") - print(f"[HL7 Parser] Error menyimpan data: {e}") - session.rollback() - finally: - session.close() - -def create_hl7_dsr_response(order, msg_control_id, qrd_segment): - """ - Membuat pesan balasan DSR^Q03 (Data Response) untuk GeneXpert. - """ - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - - # --- 1. HEADER (MSH) --- - # Field 9 (Message Type) adalah DSR^Q03 - # Field 10 (Control ID) kita generate baru - # Field 6 (Receiving Fac) harusnya GeneXpert - resp_control_id = f"RESP{timestamp}" - msh = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{timestamp}||DSR^Q03|{resp_control_id}|P|2.5" - - # --- 2. ACKNOWLEDGEMENT (MSA) --- - # AA = Application Accept (Kita mengerti pertanyaannya) - # msg_control_id = ID dari pesan QRY yang dikirim GeneXpert (Supaya dia tahu ini jawaban untuk pertanyaan yg mana) - msa = f"MSA|AA|{msg_control_id}" - - # --- 3. QUERY DEFINITION (QRD) --- - # Kita kembalikan segmen QRD yang dikirim alat (Echo back) - # qrd_segment harus string raw dari pesan masuk - qrd = qrd_segment - - # --- 4. QUERY RESPONSE STATUS (QRF) --- - # Opsional, tapi baik untuk konfirmasi - qrf = f"QRF|LIS|{timestamp}||||" - - # --- 5. DATA PASIEN & ORDER (Jika Order Ditemukan) --- - if order: - # Mapping Data - sample_id = str(order.rnoreg) - pid_norm = order.norm if order.norm else "" - first_name, last_name = split_patient_name(order.nama) - pid_nama = f"{last_name}^{first_name}" - room = str(getattr(order, "ruangan", "") or "RSSA MALANG").strip().upper() - room = room.replace("|", " ").replace("^", " ")[:30] - - # Gender - raw_gender = str(order.rjenis).upper() - pid_gender = 'M' if 'LAKI' in raw_gender or raw_gender == 'L' else 'F' - - # Test Code (Pakai Mapping yang tadi kita buat) - nama_tes_db = str(order.tes).strip() if order.tes else "" - test_code = GENEXPERT_TEST_MAPPING.get(nama_tes_db, DEFAULT_GXP_CODE) # Default: MTBRIF - - # Segmen Data - # DSP/PID/ORC/OBR tergantung setting alat. - # GeneXpert standar biasanya terima format ORM di dalam DSR atau sequence PID-ORC-OBR - - pid = f"PID|1||{pid_norm}||{pid_nama}|||{pid_gender}" - pv1 = f"PV1|1|I|{room}" - orc = f"ORC|NW|{sample_id}" - obr = f"OBR|1|{sample_id}||{test_code}^{nama_tes_db}^L|||{timestamp}" - - # Gabungkan - return f"{msh}\r{msa}\r{qrd}\r{qrf}\r{pid}\r{pv1}\r{orc}\r{obr}\r" - - else: - # Jika TIDAK ADA ORDER (Not Found) - # Kita kirim QAK (Query Acknowledge) dengan status NF (Not Found) di QRF/QAK - # Atau cukup kirim MSA|AA tapi tanpa segmen Order - return f"{msh}\r{msa}\r{qrd}\r{qrf}\r" - -def parse_myla_result(hl7_message, device_name="MYLA"): - """ - Parser khusus untuk HL7 dari bioMérieux MYLA (Kalibrasi V4.9). - Menangani hasil BACT/ALERT (Kultur Darah) dan VITEK (Identifikasi & AST). - """ - session = SessionLocal() - try: - segments = hl7_message.strip().split('\r') - - sample_id = None - patient_id = "" - patient_name = "" - result_date = datetime.datetime.now() - - kultur_id = [] - ast_results = [] - - for segment in segments: - fields = segment.split('|') - if not fields: continue - seg_type = fields[0] - - if seg_type == 'MSH': - if len(fields) > 6 and fields[6]: - try: - result_date = datetime.datetime.strptime(fields[6][:14], "%Y%m%d%H%M%S") - except: pass - - elif seg_type == 'PID': - if len(fields) > 3: patient_id = fields[3].replace('^', '') - if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() - - elif seg_type == 'OBR': - if len(fields) > 3 and fields[3]: - sample_id = fields[3].replace('^', '') - elif len(fields) > 2 and fields[2]: - sample_id = fields[2].replace('^', '') - - elif seg_type == 'OBX': - if len(fields) > 5: - test_param = fields[3].split('^')[1] if '^' in fields[3] else fields[3] - - if "Time To Detection" in test_param: - continue - - raw_val = fields[5] - val_parts = raw_val.split('^') - - if len(val_parts) > 1: - if val_parts[0] in ['<', '<=', '>', '>=', '=']: - result_val = f"{val_parts[0]} {val_parts[1]}" - else: - result_val = val_parts[1] - else: - result_val = raw_val.strip() - - interpretation = "" - if len(fields) > 8 and fields[8]: - interpretation = fields[8].split('^')[0].strip().upper() - - if interpretation in ['S', 'I', 'R', 'NS']: - ast_results.append(f"{test_param}: {result_val} ({interpretation})") - else: - kultur_id.append(f"{test_param}: {result_val}") - - final_results = [] - if kultur_id: - final_results.append("ID: " + ", ".join(kultur_id)) - if ast_results: - final_results.append("AST: " + "; ".join(ast_results)) - - final_result_str = " | ".join(final_results) if final_results else "No Data" - - if not sample_id: - sample_id = f"ERR_MYLA_{datetime.datetime.now().strftime('%H%M%S')}" - final_result_str = f"[NO_ID] {final_result_str}" - - - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=hl7_message, - organisme=final_result_str[:255], - alat=device_name - ) - session.add(new_entry) - session.commit() - - except Exception as e: - logging.error(f"[MYLA Parser] Error: {e}") - session.rollback() - finally: - session.close() - -def get_pending_myla_orders(limit=10): - session = SessionLocal() - try: - return session.query(PaslabOrder).filter( - (PaslabOrder.flg_vitek3 == False) | (PaslabOrder.flg_vitek3 == None) - ).order_by(PaslabOrder.urut.asc()).limit(limit).all() - finally: - session.close() - -def mark_myla_order_sent(order_id): - session = SessionLocal() - try: - order = session.query(PaslabOrder).filter(PaslabOrder.urut == order_id).first() - if order: - order.flg_vitek3 = True - session.commit() - return True - return False - except Exception: - session.rollback() - raise - finally: - session.close() - -def create_myla_astm_order_message(order): - """ - ASTM E1394/LIS2-A2 style: - H, P, C, O, R, L - """ - pid = sanitize_astm_field(order.norm, max_len=32) - sid = sanitize_astm_field(order.rnoreg, max_len=32) - - first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) - first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) - last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) - p_name = f"{last_name}^{first_name}" - - sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) - sex = "M" if sex_raw.startswith("L") else "F" - - raw_location = sanitize_astm_field(getattr(order, 'ruangan', "UT"), uppercase=True, max_len=40) - location = re.sub(r"[^A-Z0-9]", "", raw_location)[:10] or "UT" - - diagnosis = sanitize_astm_field(getattr(order, 'diagnosa', "Unspecified"), max_len=60) or "Unspecified" - specimen_type = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) if order.kd_spesimen else "BLOOD" - specimen_field = f"{specimen_type}^VENA^^BAIK" - test_code = sanitize_astm_field(order.tes, uppercase=True, max_len=40) or "GENERAL" - - head = r"H|\^&|||LIS|||||||||P|1" - - p_rec = [""] * 36 - p_rec[0] = "P" - p_rec[1] = "1" - p_rec[2] = pid - p_rec[3] = pid - p_rec[5] = p_name - p_rec[8] = sex - p_rec[25] = location - pat_str = "|".join(p_rec[:36]) - - com_str = f"C|1|L|{diagnosis}|G" - - o_rec = [""] * 32 - o_rec[0] = "O" - o_rec[1] = "1" - o_rec[2] = sid - o_rec[4] = f"^^^{test_code}" - o_rec[5] = "R" - o_rec[11] = "A" - o_rec[15] = specimen_field - ord_str = "|".join(o_rec[:32]) - - # Placeholder R record agar struktur record lengkap H,P,C,O,R,L. - rcd_str = "R|1|^^^ORDER_STATUS|PENDING|||N|||||F" - term = "L|1|N" - - message_content = f"{head}\r{pat_str}\r{com_str}\r{ord_str}\r{rcd_str}\r{term}\r" - seq = "1" - frame_body = f"{seq}{message_content}\x03" - chk = calculate_astm_checksum(frame_body) - full_frame = f"\x02{frame_body}{chk}\r\n" - return [full_frame.encode("latin-1")] - -def parse_myla_astm_records(raw_message, device_name="MYLA"): - session = SessionLocal() - try: - sample_id = "" - patient_id = "" - patient_name = "" - results = [] - - records = [r for r in raw_message.split('\r') if r.strip()] - for rec in records: - row = rec[1:] if rec and rec[0].isdigit() else rec - fields = row.split('|') - if not fields: - continue - - rtype = fields[0] - if rtype == "P": - if len(fields) > 3: - patient_id = fields[3].strip() - if len(fields) > 5: - patient_name = fields[5].replace("^", " ").strip() - elif rtype == "O": - if len(fields) > 2: - sample_id = fields[2].replace("^", "").strip() - elif rtype == "R": - test_name = fields[2].strip() if len(fields) > 2 else "" - test_value = fields[3].strip() if len(fields) > 3 else "" - if test_name or test_value: - results.append(f"{test_name}: {test_value}".strip(": ")) - - if sample_id and results: - final_result_str = " | ".join(results)[:255] - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=datetime.datetime.now(), - rawdt=raw_message, - organisme=final_result_str, - alat=device_name - ) - session.add(new_entry) - session.commit() - else: - print("[MYLA-ASTM] ASTM message diterima (tanpa hasil R untuk disimpan).") - except Exception as e: - logging.error(f"[MYLA-ASTM] Error parse/simpan: {e}") - session.rollback() - finally: - session.close() - -def _read_until_lf(conn, timeout_seconds=5): - deadline = time.time() + timeout_seconds - payload = b"" - while time.time() < deadline: - conn.settimeout(max(0.1, deadline - time.time())) - chunk = conn.recv(1) - if not chunk: - break - payload += chunk - if payload.endswith(b"\n"): - break - return payload - -def receive_myla_astm_transmission(conn, peer_ip, first_control=ENQ): - """ - Menerima transmisi ASTM dari server: - ENQ -> ACK, lalu STX frame(s) -> ACK/NAK tiap frame, diakhiri EOT. - """ - if first_control == ENQ: - conn.sendall(ACK) - - assembled = [] - pending_ctrl = first_control if first_control == STX else None - while True: - if pending_ctrl is not None: - ctrl = pending_ctrl - pending_ctrl = None - else: - conn.settimeout(5) - ctrl = conn.recv(1) - if not ctrl: - return - if ctrl == EOT: - break - if ctrl == ENQ: - conn.sendall(ACK) - continue - if ctrl != STX: - continue - - payload = _read_until_lf(conn, timeout_seconds=5) - if not payload: - conn.sendall(NAK) - continue - - frame = ctrl + payload - try: - frame_text = frame.decode("latin-1", errors="ignore") - body_start = frame_text.find("\x02") - etx_pos = frame_text.find("\x03") - if body_start == -1 or etx_pos == -1 or etx_pos <= body_start: - conn.sendall(NAK) - continue - - frame_body = frame_text[body_start + 1:etx_pos + 1] - recv_chk = frame_text[etx_pos + 1:etx_pos + 3].upper() - calc_chk = calculate_astm_checksum(frame_body).upper() - if recv_chk != calc_chk: - logging.warning(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") - print(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") - conn.sendall(NAK) - continue - - # Drop seq(1 char) dan ETX - data_part = frame_body[1:-1] - assembled.append(data_part) - conn.sendall(ACK) - except Exception as e: - logging.error(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") - print(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") - conn.sendall(NAK) - - if assembled: - raw_message = "".join(assembled) - parse_myla_astm_records(raw_message, device_name=f"MYLA-{peer_ip}") - -def wait_for_astm_control(conn, expected_controls, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS): - deadline = time.time() + timeout_seconds - while time.time() < deadline: - try: - conn.settimeout(max(0.1, deadline - time.time())) - b = conn.recv(1) - if not b: - return None - if b in expected_controls: - return b - if b == ENQ: - receive_myla_astm_transmission(conn, peer_ip, first_control=ENQ) - elif b == STX: - receive_myla_astm_transmission(conn, peer_ip, first_control=STX) - except socket.timeout: - continue - except Exception as e: - logging.error(f"[MYLA-ASTM] Error wait control: {e}") - print(f"[MYLA-ASTM] Error wait control: {e}") - return None - return None - -def send_order_to_myla_astm(conn, order, peer_ip): - frames = create_myla_astm_order_message(order) - conn.sendall(ENQ) - hs = wait_for_astm_control(conn, {ACK}, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS) - if hs != ACK: - logging.warning(f"[MYLA-ASTM] Handshake gagal untuk rnoreg={order.rnoreg}, respon={hs}") - print(f"[MYLA-ASTM] Handshake gagal untuk rnoreg={order.rnoreg}, respon={hs}") - return False - - for i, frame in enumerate(frames, start=1): - frame_ok = False - for attempt in range(1, 4): - conn.sendall(frame) - resp = wait_for_astm_control(conn, {ACK, NAK}, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS) - if resp == ACK: - frame_ok = True - break - if resp == NAK: - logging.warning(f"[MYLA-ASTM] Frame {i} NAK rnoreg={order.rnoreg}, retry={attempt}") - print(f"[MYLA-ASTM] Frame {i} NAK rnoreg={order.rnoreg}, retry={attempt}") - time.sleep(0.5) - continue - logging.warning(f"[MYLA-ASTM] Frame {i} timeout rnoreg={order.rnoreg}, retry={attempt}") - print(f"[MYLA-ASTM] Frame {i} timeout rnoreg={order.rnoreg}, retry={attempt}") - if not frame_ok: - conn.sendall(EOT) - return False - - conn.sendall(EOT) - return True - -def create_myla_hl7_order_message(order, msg_control_id): - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - sample_id = sanitize_astm_field(order.rnoreg, uppercase=True, max_len=32) or "000000" - pid_norm = sanitize_astm_field(order.norm, uppercase=True, max_len=32) or f"PID{sample_id}" - test_code = sanitize_astm_field(order.tes, uppercase=True, max_len=40) or "ID" - specimen_code = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) or "URC" - - # Sesuaikan pattern ID seperti contoh vendor (SPE/AWOS + accession). - compact_id = re.sub(r"[^A-Z0-9]", "", sample_id) or "000000" - spm_id = f"SPE{compact_id}"[:30] - obr_order_id = f"AWOS{compact_id}"[:30] - - msh = ( - f"MSH|^~\\&|LIS|LAB|MYLA|BMX|{timestamp}||OML^O33^OML_O33|{msg_control_id}|P|2.5.1" - f"|||NE|AL||UNICODE UTF-8" - ) - pid = f"PID|||{pid_norm}" - pv1 = "PV1||O" - spm = f"SPM|1|{spm_id}||{specimen_code}^{specimen_code}^99BMx|||||||P^^HL70369||||||{timestamp}" - sac = f"SAC|||{spm_id}" - # ORC.9 wajib ada; ikuti contoh MyLA: ORC|NW|||||||| - orc = f"ORC|NW||||||||{timestamp}" - tq1 = "TQ1|||||||||R^^HL7048" - obr = f"OBR|1|{obr_order_id}||{test_code}^{test_code}^99BMx" - return f"{msh}\r{pid}\r{pv1}\r{spm}\r{sac}\r{orc}\r{tq1}\r{obr}\r" - -def process_myla_hl7_message(conn, hl7_str, peer_ip): - incoming_control_id = "" - message_type = "" - ack_code = "" - ack_for_control_id = "" - err_text = "" - orc_control = "" - orc_status = "" - orc_ref = "" - - try: - msh_fields = hl7_str.split('\r')[0].split('|') - if len(msh_fields) > 8: - message_type = msh_fields[8].strip().upper() - if len(msh_fields) > 9: - incoming_control_id = msh_fields[9].strip() - except Exception: - pass - - if "ORU^" in message_type or message_type.startswith("OUL^R22"): - print(f"[MYLA-HL7] Menerima hasil dari {peer_ip} (type={message_type}, ID: {incoming_control_id})") - parse_myla_result(hl7_str, device_name=f"MYLA-{peer_ip}") - if incoming_control_id: - try: - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = ( - f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\r" - f"MSA|AA|{incoming_control_id}\r" - ) - conn.sendall(f"\x0b{ack_msg}\x1c\r".encode("utf-8")) - except Exception as e: - logging.warning(f"[MYLA-HL7] Gagal kirim ACK hasil {incoming_control_id}: {e}") - elif message_type.startswith("ACK") or message_type.startswith("ORL^O34"): - for seg in hl7_str.split('\r'): - if seg.startswith("MSA|"): - parts = seg.split('|') - if len(parts) > 1: - ack_code = parts[1].strip().upper() - if len(parts) > 2: - ack_for_control_id = parts[2].strip() - break - for seg in hl7_str.split('\r'): - if seg.startswith("ERR|"): - err_parts = seg.split('|') - if len(err_parts) > 2: - err_text = err_parts[2].strip() - if len(err_parts) > 3 and err_parts[3]: - err_text = f"{err_text} {err_parts[3].strip()}".strip() - break - for seg in hl7_str.split('\r'): - if seg.startswith("ORC|"): - o = seg.split('|') - if len(o) > 1: - orc_control = o[1].strip().upper() - if len(o) > 5: - orc_status = o[5].strip().upper() - if len(o) > 6: - orc_ref = o[6].strip() - break - - return { - "message_type": message_type, - "control_id": incoming_control_id, - "ack_code": ack_code, - "ack_for_control_id": ack_for_control_id, - "err_text": err_text, - "orc_control": orc_control, - "orc_status": orc_status, - "orc_ref": orc_ref, - } - -def wait_for_myla_hl7_ack(conn, expected_control_id, peer_ip, timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS): - deadline = time.time() + timeout_seconds - buffer = b"" - - while time.time() < deadline: - try: - conn.settimeout(max(0.1, deadline - time.time())) - data = conn.recv(4096) - if not data: - return False - buffer += data - - if b"\x1c\r" not in buffer: - continue - - chunks = buffer.split(b"\x1c\r") - for raw_msg in chunks[:-1]: - clean_msg = raw_msg.replace(b"\x0b", b"") - hl7_str = clean_msg.decode("latin-1", errors="ignore") - if "MSH|" not in hl7_str: - continue - - hl7_str = hl7_str[hl7_str.find("MSH|"):] - parsed = process_myla_hl7_message(conn, hl7_str, peer_ip) - if parsed["message_type"].startswith("ACK") or parsed["message_type"].startswith("ORL^O34"): - ack_for = parsed.get("ack_for_control_id", "") - ack_code = parsed.get("ack_code", "") - err_text = parsed.get("err_text", "") - orc_control = parsed.get("orc_control", "") - orc_status = parsed.get("orc_status", "") - orc_ref = parsed.get("orc_ref", "") - if ack_for == expected_control_id: - logging.info( - f"[MYLA-HL7] Response diterima untuk {expected_control_id} " - f"(type={parsed.get('message_type')}, code={ack_code or '-'}, " - f"orc1={orc_control or '-'}, orc5={orc_status or '-'}, orc6={orc_ref or '-'}, " - f"err={err_text or '-'})" - ) - app_ok = True - if parsed["message_type"].startswith("ORL^O34"): - app_ok = (orc_control == "OK") - return (ack_code in ("AA", "CA")) and app_ok - logging.info( - f"[MYLA-HL7] Response bukan untuk pesan ini " - f"(expected={expected_control_id}, msa2={ack_for or '-'}, " - f"type={parsed.get('message_type')}, code={ack_code or '-'})" - ) - - buffer = chunks[-1] - except socket.timeout: - continue - except Exception as e: - logging.error(f"[MYLA-HL7] Error wait ACK: {e}") - print(f"[MYLA-HL7] Error wait ACK: {e}") - return False - - return False - -def pump_myla_incoming(conn, peer_ip, buffer, wait_seconds=0.2): - """ - Proses pesan masuk dari MyLA saat idle (mis. ORU hasil) pada koneksi client yang sama. - """ - deadline = time.time() + max(0.05, wait_seconds) - while time.time() < deadline: - try: - conn.settimeout(max(0.05, deadline - time.time())) - data = conn.recv(4096) - if not data: - raise ConnectionError("Koneksi ditutup oleh MyLA") - buffer += data - - if b"\x1c\r" not in buffer: - continue - - chunks = buffer.split(b"\x1c\r") - for raw_msg in chunks[:-1]: - clean_msg = raw_msg.replace(b"\x0b", b"") - hl7_str = clean_msg.decode("latin-1", errors="ignore") - if "MSH|" not in hl7_str: - continue - hl7_str = hl7_str[hl7_str.find("MSH|"):] - process_myla_hl7_message(conn, hl7_str, peer_ip) - - buffer = chunks[-1] - except socket.timeout: - break - - return buffer - -def send_order_to_myla_hl7(conn, order, peer_ip): - msg_control_id = f"MYLA{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}{order.urut}" - hl7_message = create_myla_hl7_order_message(order, msg_control_id) - mllp_payload = f"\x0b{hl7_message}\x1c\r".encode("utf-8") - msh_line = hl7_message.split('\r')[0] - print(f"[MYLA-HL7] Kirim rnoreg={order.rnoreg}, MSH={msh_line}") - - conn.sendall(mllp_payload) - ack_ok = wait_for_myla_hl7_ack( - conn, - expected_control_id=msg_control_id, - peer_ip=peer_ip, - timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS - ) - return ack_ok -# ========================================== -# 4. NETWORK & COMMUNICATION LOGIC -# ========================================== -def handle_myla_client(conn, addr): - """ - TCP Handler untuk bioMérieux MYLA. - Hanya menerima HL7 berbungkus MLLP (\x0b ... \x1c\r). - """ - print(f"[MYLA-TCP] Koneksi baru dari {addr}") - buffer = b"" - - try: - while True: - data = conn.recv(4096) - if not data: break - - buffer += data - - # Cek penutup MLLP (\x1c\r) - if b'\x1c\r' in buffer: - # Pisahkan pesan jika ada beberapa pesan yang nempel - messages = buffer.split(b'\x1c\r') - - for msg in messages[:-1]: # Abaikan yang terakhir (karena string kosong atau pesan belum selesai) - # Hapus pembuka MLLP (\x0b) - clean_msg_bytes = msg.replace(b'\x0b', b'') - hl7_str = clean_msg_bytes.decode('latin-1', errors='ignore') - - if "MSH|" in hl7_str: - # Potong tepat dari MSH - hl7_str = hl7_str[hl7_str.find("MSH|"):] - - # Ambil Control ID untuk ACK - incoming_control_id = "" - try: - msh_fields = hl7_str.split('\r')[0].split('|') - if len(msh_fields) > 9: - incoming_control_id = msh_fields[9] - except: pass - - # --- PROSES HASIL (ORU / OUL^R22) --- - if "ORU^" in hl7_str or "OUL^R22" in hl7_str: - print(f"[MYLA] Menerima hasil (ORU/OUL) ID: {incoming_control_id}") - parse_myla_result(hl7_str, device_name=f"MYLA-{addr[0]}") - - # --- PROSES QUERY (QRY/QBP) - JIKA MYLA BERTANYA ORDER --- - elif "QRY^" in hl7_str or "QBP^" in hl7_str: - client_ip = addr[0] - print(f"[MYLA] Menerima Query dari {client_ip} (Control ID: {incoming_control_id})") - # Khusus MyLA gunakan flag VITEK3. - target_flag_col = "flg_vitek3" - - lines = hl7_str.split('\r') - search_sample_id = None - qrd_line = "" - - for line in lines: - if line.startswith("QRD|"): - qrd_line = line - fields = line.split('|') - if len(fields) > 8 and fields[8]: - search_sample_id = fields[8].strip() - break - - if not search_sample_id: - for line in lines: - if line.startswith("QPD|"): - qpd_fields = line.split('|') - for candidate in qpd_fields[3:]: - value = candidate.strip() - if value: - search_sample_id = value.split('^')[0].strip() - break - break - - session = SessionLocal() - try: - flag_attr = getattr(PaslabOrder, target_flag_col, None) - if flag_attr is None: - raise Exception(f"Kolom flag tidak valid: {target_flag_col}") - - order = None - if search_sample_id: - order = session.query(PaslabOrder).filter( - PaslabOrder.rnoreg == search_sample_id, - (flag_attr == False) | (flag_attr == None) - ).first() - else: - # Fallback: jika query tidak membawa sample ID, kirim order pending paling awal. - order = session.query(PaslabOrder).filter( - (flag_attr == False) | (flag_attr == None) - ).order_by(PaslabOrder.urut.asc()).first() - - if order: - reply_msg = create_hl7_dsr_response(order, incoming_control_id, qrd_line) - conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) - - setattr(order, target_flag_col, True) - session.commit() - print(f"[MYLA] Order terkirim rnoreg={order.rnoreg}, set {target_flag_col}=TRUE") - else: - reply_msg = create_hl7_dsr_response(None, incoming_control_id, qrd_line) - conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) - print(f"[MYLA] Tidak ada order pending untuk sample={search_sample_id or '-'}") - - except Exception as db_err: - session.rollback() - logging.error(f"[MYLA] Gagal proses query: {db_err}") - print(f"[MYLA] Gagal proses query: {db_err}") - reply_msg = create_hl7_dsr_response(None, incoming_control_id, qrd_line) - conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) - finally: - session.close() - continue - - # --- KIRIM ACK --- - if incoming_control_id: - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\rMSA|AA|{incoming_control_id}\r" - full_ack = f"\x0b{ack_msg}\x1c\r" - conn.sendall(full_ack.encode('utf-8')) - print(f"[MYLA ACK] Terkirim untuk ID {incoming_control_id}") - - # Sisakan bagian terakhir di buffer (kalau ada pesan yang terpotong) - buffer = messages[-1] - - except Exception as e: - logging.error(f"[MYLA-TCP] Error koneksi {addr}: {e}") - print(f"[MYLA-TCP] Error koneksi {addr}: {e}") - finally: - conn.close() - logging.info(f"[MYLA-TCP] Koneksi {addr} ditutup.") - print(f"[MYLA-TCP] Koneksi {addr} ditutup.") - -def start_myla_server(host, port): - """ - Listener berperan sebagai TCP client: - - Konek ke MYLA server - - Poll order yang belum terkirim (flg_vitek3 = False/NULL) - - Kirim order via HL7 MLLP - - Tandai terkirim jika ACK diterima - """ - while True: - conn = None - incoming_buffer = b"" - last_idle_log_at = 0.0 - try: - print(f"[MYLA-CLIENT] Mencoba konek ke MYLA {host}:{port} ...") - conn = socket.create_connection((host, port), timeout=10) - conn.settimeout(1.0) - print(f"[MYLA-CLIENT] Terhubung ke MYLA {host}:{port}") - - while True: - incoming_buffer = pump_myla_incoming(conn, host, incoming_buffer, wait_seconds=0.15) - pending_orders = get_pending_myla_orders(limit=10) - if not pending_orders: - now_ts = time.time() - if (now_ts - last_idle_log_at) >= MYLA_IDLE_LOG_INTERVAL_SECONDS: - print( - "[MYLA-CLIENT] Polling aktif: tidak ada order pending " - "(flg_vitek3 = FALSE/NULL)." - ) - last_idle_log_at = now_ts - time.sleep(MYLA_POLL_INTERVAL_SECONDS) - continue - - print(f"[MYLA-CLIENT] Ditemukan {len(pending_orders)} order pending untuk MYLA") - last_idle_log_at = 0.0 - - for order in pending_orders: - send_ok = send_order_to_myla_hl7(conn, order, host) - if send_ok: - mark_myla_order_sent(order.urut) - print(f"[MYLA-HL7] Order sukses, set flg_vitek3=TRUE rnoreg={order.rnoreg}") - else: - logging.warning( - f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " - f"akan dicoba ulang di polling berikutnya." - ) - print( - f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " - f"akan dicoba ulang di polling berikutnya." - ) - break - - time.sleep(1) - - except Exception as e: - logging.error(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") - print(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") - finally: - if conn: - try: - conn.close() - except Exception: - pass - - time.sleep(MYLA_CONNECT_RETRY_SECONDS) - -def start_myla_inbound_server(host, port): - """ - Listener TCP inbound untuk menerima hasil dari connector AI_to_LIS_MyLis (BCI/MyLA). - """ - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - server.bind((host, port)) - server.listen(5) - print(f"[MYLA-INBOUND] Listening di {host}:{port}") - - while True: - client_socket, addr = server.accept() - client_thread = threading.Thread( - target=handle_myla_client, - args=(client_socket, addr), - daemon=True - ) - client_thread.start() - except Exception as e: - logging.critical(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") - print(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") - finally: - try: - server.close() - except Exception: - pass - -# ========================================== -# HL7 TCP LISTENER FOR GENEXPERT -# ========================================== -def parse_genexpert_astm_records(astm_string, device_name): - """ - Parser khusus untuk membaca hasil ASTM dari instrumen GeneXpert - dan menyimpannya ke tabel LisPhoenix dengan aman (mencegah VARCHAR limit error). - """ - try: - records = astm_string.split('\r') - no_id = "" - rnmpas = "" - seq_no = "" - hasil_list = [] - - for rec in records: - # 1. Ambil Data Pasien (P Record) - if rec.startswith("P|"): - parts = rec.split('|') - if len(parts) > 3: - # Ambil Patient ID (Bisa di index 3 atau 4 tergantung setting alat) - no_id = parts[3].strip() or (parts[4].strip() if len(parts) > 4 else "") - if len(parts) > 5: - # Ambil Nama Pasien, ganti ^ dengan spasi - rnmpas = parts[5].replace('^', ' ').strip() - - # 2. Ambil Nomor Order / Registrasi (O Record) - elif rec.startswith("O|"): - parts = rec.split('|') - if len(parts) > 2: - seq_no = parts[2].strip() - - # 3. Ambil Hasil Tes (R Record) - elif rec.startswith("R|"): - parts = rec.split('|') - if len(parts) > 3: - test_info = parts[2] # Contoh: ^^^MTB-RIF_ULTRA 2^^^MTB^ - result_val = parts[3].replace('^', '').strip() # Contoh: DETECTED atau INVALID - - # [KUNCI]: Abaikan kurva analitik agar teks tidak kepanjangan - if "Ct|" not in rec and "EndPt|" not in rec and result_val: - # Ekstrak nama targetnya saja (misal "MTB" atau "RIF Resistance") - target_match = test_info.split('^^^') - target_name = target_match[-1].strip('^') if len(target_match) > 1 else "" - - if target_name and result_val: - hasil_list.append(f"{target_name}: {result_val}") - - # Gabungkan hasil-hasil penting menjadi 1 string - kesimpulan = " | ".join(hasil_list) - - # [PATCH KRITIS]: Potong string agar TIDAK CRASH di database - no_id_safe = no_id[:50] - seq_no_safe = seq_no[:50] - rnmpas_safe = rnmpas[:100] - kesimpulan_safe = kesimpulan[:100] # Membatasi maksimal 100 karakter untuk kolom 'organisme' - - if not seq_no_safe: - print("[GENEXPERT-PARSER] Warning: seq_no (No Order) tidak ditemukan dalam pesan hasil.") - return - - # Simpan ke Database - with SessionLocal() as session: - new_result = LisPhoenix( - no_id=no_id_safe, - seq_no=seq_no_safe, - rnmpas=rnmpas_safe, - tgl_data=datetime.datetime.now().date(), - rawdt=astm_string, # Simpan data mentahnya (utuh) ke TEXT untuk jaga-jaga/bisa dibaca ulang - organisme=kesimpulan_safe, # Hasil yang sudah bersih dan muat - alat=device_name, - ) - session.add(new_result) - session.commit() - print(f"[GENEXPERT-DB-SUCCESS] Hasil lab untuk Order {seq_no_safe} berhasil disimpan ke LisPhoenix!") - - except Exception as e: - print(f"[GENEXPERT-PARSER-ERROR] Gagal memparsing/menyimpan hasil: {e}") - import traceback - traceback.print_exc() - -def manage_tcp_server(): - """Thread Server Utama untuk GeneXpert""" - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # Allow reuse address agar tidak error 'Address already in use' saat restart - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - server.bind((SERVER_HOST, TCP_LISTENER_PORT)) - server.listen(5) # Bisa antri 5 koneksi - print(f"[TCP-SERVER] Listening GeneXpert di port {TCP_LISTENER_PORT}...") - while True: - # Accept koneksi baru (Blocking, tapi aman karena di thread sendiri) - client_sock, addr = server.accept() - - # Buat thread kecil untuk handle client tersebut (agar server bisa terima client lain) - client_thread = threading.Thread( - target=handle_genexpert_client, - args=(client_sock, addr), - daemon=True - ) - client_thread.start() - - except Exception as e: - logging.critical(f"[TCP-SERVER] Gagal Start: {e}") - print(f"[TCP-SERVER] Gagal Start: {e}") - -def run_http_api_server(): - print(f"[HTTP-API] Listening di port {HTTP_API_PORT}...") - app.run(host=SERVER_HOST, port=HTTP_API_PORT, debug=False, use_reloader=False, threaded=True) - -def process_genexpert_hl7_message(conn, ip_addr, clean_hl7, response_framing): - # ========================================================== - # 1. BLOK PENANGANAN ASTM (Karena tidak diawali "MSH|") - # ========================================================== - if not str(clean_hl7 or "").startswith("MSH|"): - records = parse_astm_records(clean_hl7) - record_types = [rec.split("|", 1)[0] for rec in records if rec] - print(f"[GENEXPERT-ASTM] ip={ip_addr}, record_types={record_types}") - - # A. Cek Jika Alat Meminta Order (Query) - if any(rec.startswith("Q|") for rec in records): - print(f"[GENEXPERT-ASTM] Alat meminta ORDER (Q Record)") - send_all_orders_astm(conn, ip_addr, clean_hl7, response_framing="astm") - return - - # B. Cek Jika Alat Mengirim Hasil Lab (Result) - if any(rec.startswith("R|") for rec in records): - print(f"[RESULT] Menerima Hasil Lab ASTM dari {ip_addr}.") - # Memanggil fungsi parser Anda untuk menyimpan hasil ke DB - parse_genexpert_astm_records(clean_hl7, device_name=f"GeneXpert-{ip_addr}") - return - - # C. [PERBAIKAN] Cek Jika Alat Mengirim Komentar/Penolakan (Comment) - if any(rec.startswith("C|") for rec in records): - # 1. Ekstrak teks komentar untuk ditampilkan di log - comments = [rec for rec in records if rec.startswith("C|")] - for c in comments: - parts = c.split('|') - comment_text = parts[3] if len(parts) > 3 else c - print(f"[GENEXPERT-ASTM-INFO] Komentar dari Alat: {comment_text}") - - # 2. Ekstrak NoReg (Nomor Order) dari record 'O' - rnoreg = None - for rec in records: - if rec.startswith("O|"): - o_parts = rec.split('|') - if len(o_parts) > 2: - rnoreg = o_parts[2].strip() - break - - # 3. Update Database PaslabOrder - if rnoreg: - # Cari kolom flag yang cocok dengan IP yang sedang terkoneksi - target_flag_col = None - for flag_col, mapped_ip in TARGET_MAPPING.items(): - if mapped_ip == ip_addr: - target_flag_col = flag_col - break - - if target_flag_col: - try: - # Buka sesi database dan update - with SessionLocal() as session: - order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == rnoreg).first() - if order: - # Set flag mesin tersebut menjadi True agar tidak dikirim ulang - setattr(order, target_flag_col, True) - session.commit() - print(f"[GENEXPERT-DB] Order {rnoreg} ditolak alat. Flag {target_flag_col} di-set True (Selesai).") - else: - print(f"[GENEXPERT-DB] Order {rnoreg} tidak ditemukan di database saat memproses penolakan.") - except Exception as e: - print(f"[GENEXPERT-DB-ERROR] Gagal update order duplikat {rnoreg}: {e}") - - print(f"[GENEXPERT-ASTM] Transaksi penolakan order selesai diproses.") - return - - # D. Jika hanya berisi H dan L tanpa ada transaksi berarti (Status Echo) - if set(record_types).issubset({'H', 'L'}): - print(f"[GENEXPERT-ASTM] Menerima Heartbeat / Sesi Kosong dari alat.") - return - - # E. GeneXpert dapat mengirim status/echo order ASTM berisi H/P/O/L. - # Ini bukan hasil lab karena tidak ada R record, dan bukan query karena tidak ada Q record. - if "O" in record_types and set(record_types).issubset({'H', 'P', 'O', 'L'}): - order_summaries = summarize_genexpert_astm_orders(records) - print( - f"[GENEXPERT-ASTM] Menerima status/echo order tanpa hasil dari {ip_addr}. " - f"orders={order_summaries}" - ) - return - - log_genexpert_hl7("GENEXPERT-ASTM", ip_addr, clean_hl7, label="pesantidakdikenali") - print(f"[GENEXPERT-ASTM] Pesan ASTM tidak dikenali dari {ip_addr}. Isi: {clean_hl7[:50]}") - return - - # ========================================================== - # 2. BLOK PENANGANAN HL7 (Fallback / Cadangan) - # ========================================================== - log_genexpert_hl7("IN", ip_addr, clean_hl7) - lines = clean_hl7.split('\r') - msh_fields = lines[0].split('|') - incoming_control_id = msh_fields[9] if len(msh_fields) > 9 else "UNKNOWN" - msg_id = incoming_control_id - - if "QRY^" in clean_hl7 or "QRY|" in clean_hl7: - print( - f"[GENEXPERT] Legacy QRY diterima dari {ip_addr} tetapi diabaikan. " - "Host hanya mendukung QBP^Z01/QBP^Z03 untuk order query." - ) - return - - if "ORU^" in clean_hl7: - # --- [PATCH] CEK APAKAH INI PESAN ERROR / PENOLAKAN --- - if "|Error^" in clean_hl7 or "|X\r" in clean_hl7 or "\rTE|" in clean_hl7: - print(f"[GENEXPERT-ERROR] Mesin {ip_addr} menolak tes (Test Unknown/Disabled).") - - # Coba ekstrak NoReg dari segmen SPM agar kita bisa mengunci ordernya (set Flag = True) - rnoreg_error = None - for line in clean_hl7.split('\r'): - if line.startswith('SPM|'): - parts = line.split('|') - if len(parts) > 2: - rnoreg_error = parts[2].replace('^', '').strip() - break - - if rnoreg_error: - print(f"[GENEXPERT-ERROR] Mengunci/Membatalkan Order {rnoreg_error} agar tidak terjadi Infinite Loop.") - # (OPSIONAL: Jalankan fungsi update ke DB untuk mengubah flag PaslabOrder menjadi True di sini) - # ... - - # Kirim ACK agar alat berhenti mengirim error - ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") - return # BERHENTI DI SINI. Jangan lanjut ke parse_hl7_result! - # ----------------------------------------------------- - - print(f"[RESULT] Menerima Hasil Lab.") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}") - ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") - print(f"[ACK SENT] Untuk hasil ID {incoming_control_id}") - return - - if "QBP^Z01" in clean_hl7 or "QBP^Z03" in clean_hl7: - print("[GENEXPERT] Alat meminta ORDER") - msg_id = extract_msg_control_id(clean_hl7) - send_all_orders(conn, ip_addr, clean_hl7, msg_id, response_framing=response_framing) - return - - if "QCN^J01" in clean_hl7: - clear_genexpert_inflight_for_ip(ip_addr, reason="query-confirmation") - ack_msg = create_genexpert_ack_j01_response(clean_hl7, ip_addr=ip_addr) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="qcn-ack") - print(f"[GENEXPERT] Menerima konfirmasi query dari {ip_addr}.") - return - - print(f"[GenExpert_TCP] Pesan Lengkap Diterima: {clean_hl7[:50]}...") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}, ") - - try: - if len(msh_fields) > 9: - msg_control_id = msh_fields[9] - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ack_time}||ACK|{msg_control_id}|P|2.5\rMSA|AA|{msg_control_id}\r" - full_ack = f"\x0b{ack_msg}\x1c\r" - log_genexpert_hl7("OUT", ip_addr, ack_msg, label="generic-ack") - conn.sendall(full_ack.encode('utf-8')) - print(f"[ACK] Terkirim untuk ID {msg_control_id}") - except Exception as e: - print(f"Gagal kirim ACK: {e}") - -def handle_genexpert_client(conn, addr): - print(f"[GenExpert_TCP] Koneksi baru dari {addr}") - buffer = b"" - pending_astm_hl7 = None - pending_astm_framing = None - conn.settimeout(60) - client_ip = addr[0] - with connection_lock: - active_genexpert_connections[client_ip] = conn - print(f"[GenExpert_TCP] Register koneksi aktif {client_ip}") - - try: - while True: - try: - data = conn.recv(4096) - if not data: - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail="reason=connection-close") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - print(f"[GenExpert_TCP] Client {addr} menutup koneksi.") - break - - buffer += data - if b"\x02" in data: - log_genexpert_handshake(addr[0], "STX-RX", detail=f"bytes={len(data)}") - if b"\x03" in data: - log_genexpert_handshake(addr[0], "ETX-RX", detail=f"bytes={len(data)}") - if b"\x04" in data: - log_genexpert_handshake(addr[0], "EOT-RX", detail=f"bytes={len(data)}") - if b"\x06" in data: - log_genexpert_handshake(addr[0], "ACK-RX", detail=f"bytes={len(data)}") - if b"\x15" in data: - log_genexpert_handshake(addr[0], "NAK-RX", detail=f"bytes={len(data)}") - - # --- 1. HANDLE HANDSHAKE (ENQ) --- - # Jika alat kirim ENQ (\x05/♣), langsung balas ACK (\x06) - if b'\x05' in buffer: - log_genexpert_handshake(addr[0], "ENQ-RX", detail=f"buffer_len={len(buffer)}") - conn.sendall(b'\x06') - log_genexpert_handshake(addr[0], "ACK-TX", detail="reason=enq") - - # [PERBAIKAN KURSIS 2]: KOSONGKAN TOTAL BUFFER SAAT ENQ! - # Alat meminta sesi baru, pastikan tidak ada sisa pesan lama yang nyangkut - buffer = b"" - pending_astm_hl7 = "" - continue # Langsung lanjut ke recv() berikutnya - if b'\x15' in buffer: - log_genexpert_handshake(addr[0], "NAK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x15', b'') - if b'\x06' in buffer: - log_genexpert_handshake(addr[0], "ACK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x06', b'') - - # --- 2. CEK APAKAH PESAN SUDAH LENGKAP? --- - # Kita cari tanda akhir pesan umum: - # - \x1c (End Block MLLP) - # - \x03 (ETX - End Text ASTM) - # - \x04 (EOT - End Transmission ASTM) - - msg_complete = False - end_marker_pos = -1 - - if b'\x1c' in buffer: # Pola MLLP Standard - end_marker_pos = buffer.find(b'\x1c') - msg_complete = True - elif b'\x03' in buffer or b'\x17' in buffer: - # Cari di index mana letak ETX atau ETB - pos_etx = buffer.find(b'\x03') - pos_etb = buffer.find(b'\x17') - - # Tentukan mana yang muncul lebih dulu di buffer - pos = -1 - if pos_etx != -1 and pos_etb != -1: - pos = min(pos_etx, pos_etb) - else: - pos = max(pos_etx, pos_etb) - - # Pastikan kita menerima 5 bytes penuh (ETB/ETX + C1 + C2 + CR + LF) - if pos != -1 and len(buffer) >= pos + 5: - end_marker_pos = pos + 5 - msg_complete = True - - elif b'\x04' in buffer: # Pola EOT (Putus Koneksi/Selesai) - end_marker_pos = buffer.find(b'\x04') - msg_complete = True - - # --- 3. PROSES JIKA LENGKAP --- - if msg_complete: - if end_marker_pos == 0 and buffer[:1] == b'\x04': - log_genexpert_handshake(addr[0], "EOT-CLEAR", detail="standalone-eot") - buffer = buffer[1:].lstrip(b'\r').lstrip(b'\n') - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail=f"framing={pending_astm_framing}") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - continue - - # Ambil pesan dari awal sampai marker - # (Gunakan slice sampai end_marker_pos+1 agar karakter penutup ikut terambil/dibuang) - if end_marker_pos == -1: end_marker_pos = len(buffer) - - full_message_bytes = buffer[:end_marker_pos] - response_framing = detect_genexpert_message_framing(full_message_bytes) - if response_framing == "astm": - debug_genexpert_astm_frame(addr[0], full_message_bytes, direction="RX") - log_genexpert_handshake( - addr[0], - "FRAME-COMPLETE", - detail=f"framing={response_framing}, frame_len={len(full_message_bytes)}" - ) - - send_genexpert_transport_ack( - conn, - addr[0], - response_framing, - reason="incoming-frame-complete" - ) - - # Sisa buffer (jika ada paket nempel di belakangnya) disimpan untuk loop berikutnya - buffer = buffer[end_marker_pos:] - - # Jangan buang EOT di sini; jika EOT datang menempel setelah frame ASTM, - # ia harus diproses pada iterasi berikutnya agar pending ASTM message dijalankan. - buffer = buffer.lstrip(b'\r').lstrip(b'\n') - - # Decode ke string - temp_str = full_message_bytes.decode('latin-1', errors='ignore') - astm_text = extract_astm_frame_text(full_message_bytes) if response_framing == "astm" else "" - - # --- SANITIZING (PEMBERSIHAN) --- - # Cari MSH pertama - if "MSH|" in temp_str: - msh_index = temp_str.find("MSH|") - clean_hl7 = temp_str[msh_index:] - if response_framing == "astm": - pending_astm_hl7 = clean_hl7 - pending_astm_framing = response_framing - log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(clean_hl7)}") - continue - process_genexpert_hl7_message(conn, addr[0], clean_hl7, response_framing) - elif response_framing == "astm" and astm_text: - pending_astm_hl7 = (pending_astm_hl7 or "") + astm_text - pending_astm_framing = response_framing - log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(pending_astm_hl7)}, mode=records") - continue - else: - # Jika pesan lengkap tapi tidak ada MSH (misal cuma EOT doang) - pass - else: - if buffer: - head_hex = buffer[:12].hex() - log_genexpert_handshake( - addr[0], - "BUFFER-WAIT", - detail=f"buffer_len={len(buffer)}, head_hex={head_hex}" - ) - - except ConnectionResetError: - logging.warning(f"[GenExpert_TCP] Connection reset by peer: {addr}") - break - except OSError as e: - if getattr(e, "winerror", None) == 10054: - logging.warning(f"[GenExpert_TCP] WinError 10054 dari {addr}") - break - raise - except socket.timeout: - continue - except Exception as e: - print(f"[Loop Error] {e}") - logging.exception(f"[Loop Error] Unexpected error from {addr}: {e}") - break - - except Exception as e: - logging.error(f"[GenExpert_TCP Error] Koneksi {addr} terputus: {e}") - finally: - with connection_lock: - if active_genexpert_connections.get(client_ip) is conn: - del active_genexpert_connections[client_ip] - remaining_connections = len(active_genexpert_connections) - clear_genexpert_inflight_for_ip(client_ip, reason="connection-closed") - if remaining_connections == 0: - stop_all_scheduled_result_queries(reason="no-active-genexpert") - try: - conn.close() - except Exception: - pass - logging.info(f"[GenExpert_TCP] Koneksi {addr} ditutup.") - -def send_order_via_active_connection(target_ip, hl7_message): - conn = None - with connection_lock: - conn = active_genexpert_connections.get(target_ip) - - if not conn: - logging.warning(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") - print(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") - return False - - try: - # Bungkus pesan dengan MLLP (Minimal Lower Layer Protocol) standard HL7 - # Format: message - mllp_msg = f"\x0b{hl7_message}\x1c\r" - - logging.info(f"Mengirim Order ke {target_ip}...") - print(f"Mengirim Order ke {target_ip}...") - conn.sendall(mllp_msg.encode('utf-8')) - - # Opsi: Jika ingin menunggu ACK balasan untuk Order - # Namun hati-hati ini bisa blocking jika alat lambat - ack = conn.recv(1024) - logging.info(f"Dapat ACK Order dari {target_ip}: {ack}") - print(f"Dapat ACK Order dari {target_ip}: {ack}") - return True - except Exception as e: - logging.error(f"Error mengirim ke {target_ip}: {e}") - print(f"Error mengirim ke {target_ip}: {e}") - # Jika error saat kirim, anggap koneksi rusak - with connection_lock: - if target_ip in active_genexpert_connections: - del active_genexpert_connections[target_ip] - return False - -# ========================================== -# VITEK PARSER -# ========================================== - -def calculate_vitek_checksum(data_str): - """ - Menghitung Checksum Vitek (Sum of bytes % 256). - """ - total = sum(ord(c) for c in data_str) - return f"{total % 256:02X}" - -def parse_and_save_vitek_result(raw_data, port_name="VITEK"): - session = SessionLocal() - try: - # --- 1. CLEANING DATA --- - # Hapus karakter kontrol STX(02), ETX(03), RS(1e/30), GS(1d/29), CR, LF - # Perhatikan: Log Anda menunjukkan RS () muncul di tengah kata, jadi harus dihapus total. - clean_data = raw_data.replace('\x02', '').replace('\x03', '').replace('\x1e', '').replace('\x1d', '').replace('\r', '').replace('\n', '') - - # Pisahkan field berdasarkan pipa '|' - fields = clean_data.split('|') - - # Cek apakah ini pesan result (mtrsl) - if not fields or fields[0] != 'mtrsl': - return # Abaikan jika bukan result - - # --- 2. VARIABLE INIT --- - sample_id = None # ci (No Lab / No Container) - patient_id = "" # pi (No RM) - patient_name = "" # pn - organism_name = "" # o2 - card_barcode = "" # is - antibiotics = [] # List penampung hasil AB - result_date = datetime.datetime.now() - - # Variabel sementara untuk looping antibiotik - curr_ab_name = "" - curr_ab_mic = "" - curr_ab_int = "" - - # --- 3. PARSING LOOP --- - for field in fields: - if not field: continue - - # --- HEADER INFO --- - if field.startswith("ci") and len(field) > 2: - sample_id = field[2:].strip() - elif field.startswith("pi") and len(field) > 2: - patient_id = field[2:].strip() - elif field.startswith("pn") and len(field) > 2: - patient_name = field[2:].strip() - elif field.startswith("is") and len(field) > 2: - card_barcode = field[2:].strip() - # --- ORGANISME --- - elif field.startswith("o2") and len(field) > 2: - organism_name = field[2:].strip() - - # --- ANTIBIOTIK BLOCK (Mulai Pesan Kedua) --- - # Tag 'ra' adalah pemisah antar obat - elif field == "ra": - # Jika ada data obat sebelumnya di memori, simpan dulu - if curr_ab_name: - ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" - antibiotics.append(ab_str) - # Reset untuk obat berikutnya - curr_ab_name = ""; curr_ab_mic = ""; curr_ab_int = "" - - # Detail Obat - elif field.startswith("a2") and len(field) > 2: # Nama Obat (mis: Cefoxitin) - curr_ab_name = field[2:].strip() - elif field.startswith("a3") and len(field) > 2: # MIC (mis: >=4) - curr_ab_mic = field[2:].strip() - elif field.startswith("a4") and len(field) > 2: # Interpretasi (R/S/I) - curr_ab_int = field[2:].strip() - elif field.startswith("an") and len(field) > 2: # Interpretasi Alternatif - if not curr_ab_int: curr_ab_int = field[2:].strip() - - # Jangan lupa simpan obat terakhir yang tersisa di buffer - if curr_ab_name: - ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" - antibiotics.append(ab_str) - - # --- 4. FORMAT FINAL STRING --- - # Format: "Staphylococcus hominis | Cefoxitin >=4 (R), Gentamicin 4 (S)..." - final_res_string = organism_name - #if antibiotics: - # final_res_string += " | " + ", ".join(antibiotics) - - # Fallback jika negatif (biasanya tidak ada o2, tapi ada teks neg) - #if not final_res_string and "neg" in raw_data.lower(): - # final_res_string = "NEGATIVE / NO GROWTH" - - # --- 5. LOGIKA DATABASE (UPSERT: UPDATE or INSERT) --- - if sample_id: - # Cari data berdasarkan No Lab (Sample ID) - final_seq_no = card_barcode if card_barcode else patient_id - existing_data = session.query(LisPhoenix).filter( - LisPhoenix.seq_no == final_seq_no - ).first() - - if existing_data: - # === SKENARIO PESAN KEDUA (UPDATE) === - print(f"[{port_name}] UPDATE Data -> ID: {sample_id} (Hasil Lengkap)") - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=final_seq_no, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - - else: - # === SKENARIO PESAN PERTAMA (INSERT) === - print(f"[{port_name}] INSERT Data -> ID: {sample_id} (Identifikasi Awal)") - - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=final_seq_no, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - - session.commit() - else: - logging.warning(f"[{port_name}] Pesan diabaikan (Tanpa Sample ID): {clean_data[:30]}...") - - except Exception as e: - logging.error(f"Error Parsing Vitek: {e}") - print(f"Error Parsing Vitek: {e}") - session.rollback() - finally: - session.close() - -def split_patient_name(full_name): - """ - Return: last_name, first_name - ASTM format: LAST^FIRST - """ - - if not full_name: - return "NAME", "NO" - - raw = str(full_name).strip() - - if not raw: - return "NAME", "NO" - - first_name = "" - last_name = "" - - # Hapus karakter ASTM berbahaya - raw = ( - raw.replace("\x00", "") - .replace("\r", " ") - .replace("\n", " ") - .replace("|", " ") - .replace("&", " ") - ) - - if "^" in raw: - parts = [p.strip() for p in raw.split("^")] - - last_name = parts[0] if len(parts) > 0 and parts[0] else "NAME" - first_name = parts[1] if len(parts) > 1 and parts[1] else "NO" - - else: - tokens = raw.split() - - if len(tokens) >= 2: - first_name = " ".join(tokens[:-1]) - last_name = tokens[-1] - elif len(tokens) == 1: - first_name = tokens[0] - last_name = "NAME" - - first_name = first_name.upper()[:20] - last_name = last_name.upper()[:20] - - return last_name, first_name - -def sanitize_astm_field(value, *, uppercase=False, max_len=None, allow_component_sep=False): - """ - Sanitasi field agar aman untuk ASTM: - - Buang karakter NULL/control (termasuk CR/LF) - - Hilangkan delimiter ASTM pada field biasa - - Trim dan optional uppercase/truncate - """ - if value is None: - text = "" - else: - text = str(value) - - # Hilangkan NULL byte yang sering muncul dari CHAR/VARCHAR bermasalah. - text = text.replace("\x00", "") - # Buang karakter kontrol non-printable. - text = re.sub(r"[\x00-\x1f\x7f]", " ", text) - - if allow_component_sep: - text = text.replace("|", " ") - else: - text = text.replace("|", " ").replace("^", " ") - - text = re.sub(r"\s+", " ", text).strip() - - # ASTM payload dikirim dengan latin-1; karakter di luar rentang ini diganti aman. - text = text.encode("latin-1", errors="replace").decode("latin-1") - - if uppercase: - text = text.upper() - if max_len is not None: - text = text[:max_len] - return text - -def create_vitek_order_message(order): - """ - Membuat Frame Order Vitek sesuai Manual Ref 514937. - Format: [DATA] [CS] - """ - # --- 1. ISI PESAN (CONTENT) --- - # Field Delimiter menggunakan Pipe '|' - pid = str(order.norm).strip() if order.norm else "" - sid = str(order.rnoreg).strip() if order.rnoreg else "" - first_name, last_name = split_patient_name(order.nama) - p_name = f"{last_name}^{first_name}" - specimen = str(order.kd_spesimen).upper() if order.kd_spesimen else "BLOOD" - room = str(getattr(order, 'ruangan', "") or "RSSA MALANG").strip().upper() - # VITEK: Patient Location Code menggunakan tag "pl" (maks 40 char pada spec yang dipakai) - room = room.replace("|", " ").replace("^", " ")[:40] - - now = datetime.datetime.now() - date_str = now.strftime("%m/%d/%Y") - time_str = now.strftime("%H:%M") - - # Struktur mtmpr sesuai Table 1-3 & Contoh Manual - # Penting: Tidak ada Sequence Number '1' di dalam data - content_body = ( - f"mtmpr|pi{pid}|pn{p_name}" - f"|si|ss{specimen}" - f"|pl{room}" - f"|s1{date_str}|s2{time_str}" - f"|ci{sid}|t11|zz" - ) - - # --- 2. FRAMING & CHECKSUM (Section 2.2 & 2.5) --- - # Frame dimulai dengan STX, lalu Record dimulai dengan RS - STX = b'\x02' - RS = b'\x1e' - GS = b'\x1d' - ETX = b'\x03' - CRLF = b'\r\n' - - # Data yang dihitung Checksumnya: [RS] + [Body] + [GS] - # Manual Hal 2-10: "calculated by adding the value of all characters beginning with the first ... and ending with the " - payload_for_checksum = RS + content_body.encode('latin-1') + GS - - # Hitung Checksum - total_sum = sum(payload_for_checksum) - chk_val = total_sum % 256 - checksum_str = f"{chk_val:02X}".encode('latin-1') # Hex 2 digit uppercase - - # Rakit Frame Utuh - # [PAYLOAD+GS] [CHECKSUM] - # Perhatikan: Payload di atas sudah mengandung RS dan GS - full_frame = STX + payload_for_checksum + checksum_str + ETX + CRLF - - return [full_frame] - -def manage_vitek_port(config): - port_name = config['port'] - flag_col = config.get('flag_column') - alat_name = config.get('alat_name', 'VITEK') - - print(f"[{port_name}] START VITEK SERVICE (Relaxed Mode)...") - STUCK_WINDOW_SEC = 120 - stuck_count = 0 - stuck_window_start = None - while True: - try: - with serial.Serial( - port=port_name, - baudrate=config['baud_rate'], - timeout=2, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - xonxoff=False, - rtscts=False, - dsrdtr=False - ) as ser: - - ser.reset_input_buffer() - print(f"[{port_name}] Ready & Listening.") - - while True: - # ========================================== - # PHASE 1: LISTENING - # ========================================== - if ser.in_waiting > 0: - header = ser.read(1) - - # --- HANDSHAKE --- - if header == b'\x05': - print(f"[{port_name}] Got ENQ -> Reply ACK") - ser.write(b'\x06') - ser.reset_input_buffer() - - # --- DATA FRAME --- - elif header == b'\x02': - print(f"[{port_name}] Frame Start. Reading...") - original_timeout = ser.timeout - ser.timeout = 8 - body = ser.read_until(b'\x03') - ser.timeout = original_timeout - - full_frame = header + body - - - is_valid = False - - if full_frame.endswith(b'\x03'): - is_valid = True - elif b'\x1d' in full_frame[-20:]: - is_valid = True - print(f"[{port_name}] Frame tanpa ETX tapi ada Checksum. Menerima paksa...") - if is_valid: - print(f"[{port_name}] Frame OK -> ACK Sent.") - # 1. KIRIM ACK (WAJIB) - ser.write(b'\x06') - stuck_count = 0 - stuck_window_start = None - - # 2. Proses Data - try: - full_str = full_frame.decode('latin-1', errors='ignore') - # Debug - # print(f"[{port_name}] CONTENT: {full_str}") - parse_and_save_vitek_result(full_str, alat_name) - except Exception as e: - logging.error(f"[{port_name}] Parse Err: {e}") - print(f"[{port_name}] Parse Err: {e}") - else: - logging.warning(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") - print(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") - ser.write(b'\x15') # NAK - - # --- EOT --- - elif header == b'\x04': - logging.info(f"[{port_name}] Session End (EOT).") - print(f"[{port_name}] Session End (EOT).") - ser.reset_input_buffer() - stuck_count = 0 - stuck_window_start = None - - else: - pass - - # ========================================== - # PHASE 2: SENDING ORDER (JIKA IDLE) - # ========================================== - else: - # Kita masuk sini jika ser.in_waiting == 0 (Sepi) - # Pastikan kolom flag diset di config - if flag_col: - session = None - try: - session = SessionLocal() - # Cari order yang belum dikirim - pending_order = session.query(PaslabOrder).filter( - getattr(PaslabOrder, flag_col) == False - ).first() - - if pending_order: - print(f"[{port_name}] Ada Order: {pending_order.rnoreg}...") - - # --- LOGIC HANDSHAKE DENGAN RETRY --- - handshake_success = False - - # Coba kirim ENQ max 3 kali - for attempt in range(3): - ser.reset_input_buffer() - ser.write(b'\x05') # Kirim ENQ - time.sleep(0.5) # Tunggu balasan - - if ser.in_waiting: - resp = ser.read(1) - if resp == b'\x06': # Dapat ACK - handshake_success = True - break - elif resp == b'\x15': # Dapat NAK - time.sleep(1) - else: - # Timeout, alat diam saja - pass - - if handshake_success: - stuck_count = 0 - stuck_window_start = None - # === KIRIM DATA ORDER === - print(f"[{port_name}] Handshake OK. Kirim Frames...") - frames = create_vitek_order_message(pending_order) - all_sent = True - - for frame in frames: - ser.write(frame) - # Tunggu ACK per frame - got_ack = False - wait_start = time.time() - while time.time() - wait_start < 3: - if ser.in_waiting: - if ser.read(1) == b'\x06': - got_ack = True - break - - if not got_ack: - all_sent = False - break - - # Tutup Sesi - ser.write(b'\x04') # EOT - - if all_sent: - print(f"[{port_name}] Order SELESAI Terkirim.") - setattr(pending_order, flag_col, True) - session.commit() - stuck_count = 0 - stuck_window_start = None - else: - logging.error(f"[{port_name}] Order Gagal (No ACK).") - print(f"[{port_name}] Order Gagal (No ACK).") - - else: - # === FORCE RESET (ANTI-STUCK) === - # Jika sudah 3x ENQ tidak dibalas, anggap alat 'bengong' - # Kirim EOT untuk mereset status alat - print(f"[{port_name}] Alat Sibuk/Stuck. Kirim Force EOT.") - ser.write(b'\x04') - time.sleep(2.0) - now_ts = time.time() - if not stuck_window_start or (now_ts - stuck_window_start) > STUCK_WINDOW_SEC: - stuck_window_start = now_ts - stuck_count = 1 - else: - stuck_count += 1 - if stuck_count >= 3: - # Jika sudah stuck berulang dalam window waktu, restart koneksi serial - logging.critical( - f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." - ) - print( - f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." - ) - try: - ser.reset_input_buffer() - ser.reset_output_buffer() - except Exception: - pass - raise RuntimeError("Vitek stuck berulang, restart port.") - - except Exception as e: - logging.error(f"[{port_name}] Sending Error: {e}") - print(f"[{port_name}] Sending Error: {e}") - finally: - if session: session.close() - - # Sleep penting agar CPU tidak 100% saat idle - time.sleep(0.1) - - - except Exception as e: - logging.critical(f"[{port_name}] Serial Crash: {e}") - print(f"[{port_name}] Serial Crash: {e}") - time.sleep(5) - -# ========================================== -# BECTON DICKINSON (BD) BACTEC PARSER -# ========================================== - -def parse_and_save_bd_result(raw_data, port_name="BD Bactec"): - session = SessionLocal() - try: - # --- LANGKAH 1: REASSEMBLY (JAHIT FRAME) --- - raw_frames = raw_data.split('\x02') - full_content = "" - - for frame in raw_frames: - if not frame: continue - content_chunk = frame - # Hapus Checksum & ETX/ETB - if '\x03' in frame: content_chunk = frame.split('\x03')[0] - elif '\x17' in frame: content_chunk = frame.split('\x17')[0] - - # Hapus Sequence Number di awal - if content_chunk and content_chunk[0].isdigit(): - content_chunk = content_chunk[1:] - - full_content += content_chunk - - # --- LANGKAH 2: PARSING FIELD --- - lines = full_content.split('\r') - - sample_id = None - patient_id = "" - patient_name = "" - specimen_type = "" - result_val = "" - result_date = datetime.datetime.now() - - for line in lines: - line = line.strip() - if not line: continue - fields = line.split('|') - record_type = fields[0] - - # --- PATIENT (P) --- - if record_type == 'P': - # P|Seq|?|PID||Name - if len(fields) > 3: patient_id = fields[3].strip() - if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() - - # --- ORDER (O) --- - elif record_type == 'O': - # O|Seq|SampleID|...|...|...|...|...|...|...|...|...|...|...|Specimen - if len(fields) > 2: - sample_id = fields[2].replace('^', '').strip() - - # Ambil Spesimen (Biasanya di index 15 / Field 16) - if len(fields) > 15: - specimen_type = fields[15].strip() - - # --- RESULT (R) --- - elif record_type == 'R': - # R|Seq|Test|Result|...|...|...|...|...|...|StartDate|EndDate - if len(fields) > 3: - raw_res = fields[3].strip() - # Bersihkan hasil - if "NEGATIVE" in raw_res: result_val = "NEGATIVE" - elif "POSITIVE" in raw_res: result_val = "POSITIVE" - else: result_val = raw_res.split('^')[0] # Ambil kode depan saja - - # Ambil Tanggal Hasil Selesai (Index 12 / Field 13) - if len(fields) > 12 and len(fields[12]) >= 14: - try: - # Format BD: YYYYMMDDHHMMSS (20251006100257) - res_dt_str = fields[12][:14] - result_date = datetime.datetime.strptime(res_dt_str, "%Y%m%d%H%M%S") - except: - pass # Gunakan default jika gagal parse - - # --- LANGKAH 3: FORMAT FINAL & SAVE --- - if sample_id and result_val: - - final_res_string = result_val - if specimen_type: - final_res_string += f" ({specimen_type})" - - print(f"[{port_name}] Save DB -> ID: {sample_id}, Pasien: {patient_name}, Hasil: {final_res_string}") - new_entry = LisPhoenix( - no_id=sample_id, - seq_no=patient_id, - rnmpas=patient_name, - tgl_data=result_date, - rawdt=raw_data, - organisme=final_res_string, - alat=port_name - ) - session.add(new_entry) - session.commit() - else: - logging.warning(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") - print(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") - - except Exception as e: - logging.error(f"Error Parsing BD: {e}") - print(f"Error Parsing BD: {e}") - session.rollback() - finally: - session.close() - -def calculate_astm_checksum(frame_content): - data_bytes = frame_content.encode('latin-1') - checksum = sum(data_bytes) % 256 - return f"{checksum:02X}" - -def create_astm_order_message(order): - """ - Membuat 1 Frame ASTM Single Block (H, P, O, L) dengan Mapping Index PRESISI. - Menghindari pergeseran kolom (shifting error). - """ - # --- 1. PERSIAPAN DATA --- - pid = sanitize_astm_field(order.norm, max_len=32) - sid = sanitize_astm_field(order.rnoreg, max_len=32) - # Nama pasien dipisah agar mengikuti format ASTM: Last^First - first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) - first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) - last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) - p_name = f"{last_name}^{first_name}" - sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) - sex = "M" if sex_raw.startswith("L") else "F" - - # Lokasi / Ruangan (Field 26) sebaiknya berupa kode singkat agar tidak ditrunkasi LIS. - raw_location = sanitize_astm_field(getattr(order, 'ruangan', "UT"), uppercase=True, max_len=10) - location = re.sub(r"[^A-Z0-9]", "", raw_location)[:10] - if not location: - location = "UT" - - # Diagnosis (Clinical Info - Field 14 di ASTM standar atau 13 di beberapa varian) - # Kita pasang di Index 13 (Field 14) agar aman - diagnosis = sanitize_astm_field(getattr(order, 'diagnosa', "Unspecified"), max_len=60) - if not diagnosis: diagnosis = "Unspecified" - - # Specimen Info (Field 16) - # Format: SpecimenType^BodySite^Container^Condition - specimen_type = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) if order.kd_spesimen else "BLOOD" - body_site = "VENA" # Site - condition = "BAIK" # Condition - specimen_field = f"{specimen_type}^{body_site}^^{condition}" - - # --- 2. KONSTRUKSI RECORD DENGAN INDEX PASTI --- - - # --- RECORD HEADER (H) --- - h_rec = [""] * 14 - - h_rec[0] = "H" # H,1 Record Type - h_rec[1] = r"\^&" # H,2 Delimiter - h_rec[4] = "MyLIS" # H,5 Sender Name - h_rec[12] = "V1.00" # H,13 Version - h_rec[13] = datetime.datetime.now().strftime("%Y%m%d%H%M%S") # H,14 Message DateTime - - head = "|".join(h_rec) - # --- RECORD PATIENT (P) --- - # Kita buat array kosong sebanyak 30 kolom dulu - p_rec = [""] * 35 - p_rec[0] = "P" # Field 1: Record Type - p_rec[1] = "1" # Field 2: Sequence - p_rec[2] = pid # Field 3: Patient ID (Practice) - p_rec[3] = pid # Field 4: Lab ID (Kosong) - p_rec[4] = "" # Field 5: ID 3 (Kosong) - p_rec[5] = p_name # Field 6: Patient Name (Index 5) <--- SEBELUMNYA SALAH DISINI - p_rec[7] = "" # Field 8: Birthdate - p_rec[8] = sex # Field 9: Sex (Index 8) - # ... Field 10-25 biarkan kosong ... - p_rec[25] = "1" # Field 26: Location (Index 25) - p_rec[32] = "MIKRO" # Hospital Service - p_rec[33] = raw_location# Hospital Client (Raw, untuk referensi internal) - - # Potong array sampai index 26 saja (sisanya buang) lalu gabung - pat_str = "|".join(p_rec[:35]) - - # --- RECORD ORDER (O) --- - o_rec = [""] * 30 - o_rec[0] = "O" # Field 1 - o_rec[1] = "1" # Field 2 - o_rec[2] = sid # Field 3: Sample ID - o_rec[3] = "" # Field 4: Instrument Specimen ID - o_rec[4] = "^^^" # Field 5: Universal Test ID - o_rec[5] = "R" # Field 6: Priority - # ... Field 7-11 ... - o_rec[11] = "A" # Field 12: Action Code (A=Add, N=New) (Index 11) - o_rec[12] = diagnosis # Field 13: Clinical Info / Diagnosis (Index 12) - # ... Field 14-15 ... - o_rec[15] = specimen_field # Field 16: Specimen Source (Index 15) - - # Potong array sampai index 16 (atau lebih jika BD butuh field belakang) - # Kita ambil aman sampai field 20 - ord_str = "|".join(o_rec[:20]) - - # --- RECORD TERMINATOR (L) --- - term = "L|1|N" - - # --- 3. GABUNG FRAME --- - # Gunakan \r (Carriage Return) sebagai pemisah record - message_content = f"{head}\r{pat_str}\r{ord_str}\r{term}" - - # Sequence Frame = 1 - seq = "1" - - # Isi Frame: [Seq] [Data] [ETX] - frame_body = f"{seq}{message_content}\r\x03" - - # Hitung Checksum - chk = calculate_astm_checksum(frame_body) - - # Full Frame - full_frame = f"\x02{frame_body}{chk}\r\n" - - return [full_frame.encode('latin-1')] - -def manage_bd_port(config): - port_name = config['port'] - flag_col = config.get('flag_column') - alat_name = config.get('alat_name', 'BD') - print(f"[{port_name}] Membuka port untuk alat {alat_name}...") - - # Buffer untuk menampung pecahan data - rx_buffer = "" - - try: - with serial.Serial( - port=port_name, - baudrate=config['baud_rate'], - timeout=1 - ) as ser: - - while True: - has_activity = False # Penanda agar kita sleep kalau sepi - - # ========================================== - # PHASE 1: LISTENING (PRIORITAS UTAMA) - # ========================================== - try: - if ser.in_waiting > 0: - has_activity = True - data_chunk = ser.read(ser.in_waiting or 1024) - - if data_chunk: - try: - # 1. Handle Handshake Awal (ENQ) - if b'\x05' in data_chunk: - print(f"[{port_name}] Terima ENQ (Alat mau kirim data). Kirim ACK.") - ser.write(b'\x06') # ACK - rx_buffer = "" # Reset buffer untuk data baru - data_chunk = data_chunk.replace(b'\x05', b'') - - # 2. Simpan semua byte data yang tersisa. - # Chunk lanjutan frame bisa datang tanpa STX, jadi tidak boleh dibuang. - chunk_without_eot = data_chunk.replace(b'\x04', b'') - if chunk_without_eot: - rx_buffer += chunk_without_eot.decode('latin-1', errors='ignore') - - # 3. Handle Data Frame Normal (STX ... ETX/ETB) - # ASTM butuh ACK setiap frame baru agar alat lanjut kirim frame berikutnya. - if b'\x02' in data_chunk: - ser.write(b'\x06') - # logging.debug(f"[{port_name}] Frame diterima, ACK dikirim.") - print(f"[{port_name}] Frame diterima, ACK dikirim.") - - # 4. Handle Akhir Transmisi (EOT) - if b'\x04' in data_chunk: - print(f"[{port_name}] Terima EOT (Selesai). Memproses data...") - parse_and_save_bd_result(rx_buffer, alat_name) - rx_buffer = "" # Kosongkan buffer setelah save - - except Exception as e: - logging.error(f"Error decode frame: {e}") - print(f"Error decode frame: {e}") - - continue # Loop lagi untuk ambil sisa data - - except Exception as e: - logging.error(f"[{port_name}] Error Reading: {e}") - print(f"[{port_name}] Error Reading: {e}") - rx_buffer = "" # Reset jika error parah - - - # ========================================== - # PHASE 2: SENDING ORDER (JIKA BUFFER KOSONG) - # ========================================== - # Kita hanya kirim order jika sedang tidak menerima data (buffer kosong) - if not rx_buffer and flag_col: - try: - session = SessionLocal() - # Cari order yang belum dikirim - pending_order = session.query(PaslabOrder).filter( - getattr(PaslabOrder, flag_col) == False - ).first() - - if pending_order: - has_activity = True # Jangan sleep lama-lama - print(f"[{port_name}] Menemukan Order: {pending_order.rnoreg}. Memulai Handshake...") - # --- STEP 1: HANDSHAKE (ENQ) --- - ser.reset_input_buffer() - ser.write(b'\x05') - time.sleep(0.5) - - ack_response = ser.read(1) - - if ack_response == b'\x06': - print(f"[{port_name}] Handshake Sukses (Dapat ACK). Menunggu alat siap...") - # --- PERBAIKAN 1: BERI JEDA SETELAH HANDSHAKE --- - # Mesin butuh napas sebelum terima data panjang - time.sleep(1.5) # Jeda 1.5 detik - - frames = create_astm_order_message(pending_order) - all_frames_sent = True - - # --------------------------------------------------- - # STEP 2: SEND FRAMES WITH RETRY - # --------------------------------------------------- - for i, frame in enumerate(frames): - retry_count = 0 - max_retries = 3 - frame_success = False - - while retry_count < max_retries: - ser.reset_input_buffer() - - print(f"[{port_name}] Kirim Frame {i+1} (Percobaan {retry_count+1})...") - ser.write(frame) - - # Tunggu ACK - original_timeout = ser.timeout - ser.timeout = 3 # Beri waktu agak lama (3 detik) untuk alat memproses data - frame_ack = ser.read(1) - ser.timeout = original_timeout - - if frame_ack == b'\x06': # ACK (Sukses) - frame_success = True - print(f"[{port_name}] Frame {i+1} ACK diterima.") - # Beri jeda dikit sebelum kirim frame berikutnya - time.sleep(0.2) - break - - elif frame_ack == b'\x15': # NAK (Ditolak - Checksum Salah) - print(f"[{port_name}] Frame ditolak (NAK). Checksum mungkin salah.") - time.sleep(2) # Tunggu 2 detik - retry_count += 1 - - elif not frame_ack: # Timeout (Sepi) - # --- PERBAIKAN 2: BERI JEDA SAAT TIMEOUT --- - print(f"[{port_name}] Timeout (Alat diam). Menunggu sebelum retry...") - time.sleep(2) # Tunggu 2 detik agar alat recover - retry_count += 1 - - else: - logging.warning(f"[{port_name}] Respon aneh: {frame_ack}") - print(f"[{port_name}] Respon aneh: {frame_ack}") - time.sleep(1) - retry_count += 1 - - if not frame_success: - logging.error(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") - print(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") - all_frames_sent = False - break - # --------------------------------------------------- - # STEP 3: FINALIZE - # --------------------------------------------------- - if all_frames_sent: - ser.write(b'\x04') # EOT (End of Transmission) - print(f"[{port_name}] Order {pending_order.rnoreg} SUKSES Terkirim.") - # Update Database - setattr(pending_order, flag_col, True) - session.commit() - else: - ser.write(b'\x04') # EOT (Putus paksa karena error) - logging.error(f"[{port_name}] Pengiriman Order GAGAL.") - print(f"[{port_name}] Pengiriman Order GAGAL.") - else: - # Jika Handshake gagal (Dibalas NAK, atau Timeout) - logging.warning(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") - print(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") - # Jangan update flag DB, biarkan coba lagi nanti - - - session.close() - - except Exception as e: - logging.error(f"[{port_name}] Error Sending Logic: {e}") - print(f"[{port_name}] Error Sending Logic: {e}") - if 'session' in locals(): session.close() - - # ========================================== - # PHASE 3: IDLE MANAGEMENT - # ========================================== - # Jika tidak ada data masuk dan tidak ada order keluar, tidur sebentar - # Ini penting agar CPU tidak 100% dan DB tidak jebol - if not has_activity: - time.sleep(1.0) - - except Exception as e: - logging.critical(f"[{port_name}] Gagal connect Serial: {e}") - print(f"[{port_name}] Gagal connect Serial: {e}") - time.sleep(5) - -# ========================================== -# 5. Serial Manager -# ========================================== - -def manage_serial_port(config): - """Fungsi router yang memilih manajer yang tepat berdasarkan tipe alat.""" - device_type = config.get('device_type') - if device_type == 'vitek': - manage_vitek_port(config) - elif device_type in ['bd_mgit', 'bd_bactec', 'bd']: - manage_bd_port(config) - else: - print(f"Tipe alat tidak diketahui: '{device_type}' untuk port {config.get('port')}. Thread dihentikan.") - -# ========================================== -# 6. MAIN EXECUTION -# ========================================== -if __name__ == "__main__": - print("--- MEMULAI GENE XPERT ONLY LISTENER ---") - - all_threads = [] - t_tcp = threading.Thread(target=manage_tcp_server, name="Manager-TCP-GeneXpert", daemon=True) - t_tcp.start() - all_threads.append(t_tcp) - - try: - while True: - print(f"--- Monitoring {len(all_threads)} Threads ---") - alive_count = 0 - for t in all_threads: - if t.is_alive(): - alive_count += 1 - else: - print(f"!!! THREAD MATI: {t.name} !!!") - - if alive_count == 0: - print("Semua thread GeneXpert mati. System Shutdown.") - break - - time.sleep(10) - - except KeyboardInterrupt: - print("Mematikan GeneXpert Listener (Ctrl+C)...") diff --git a/listener/requirements.txt b/listener/requirements.txt deleted file mode 100644 index 55263398..00000000 --- a/listener/requirements.txt +++ /dev/null @@ -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 diff --git a/mylis/.gitignore b/mylis/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/mylis/.gitignore +++ /dev/null @@ -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 diff --git a/mylis/.metadata b/mylis/.metadata deleted file mode 100644 index 2459a95d..00000000 --- a/mylis/.metadata +++ /dev/null @@ -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' diff --git a/mylis/README.md b/mylis/README.md deleted file mode 100644 index 0f8a5fb2..00000000 --- a/mylis/README.md +++ /dev/null @@ -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. diff --git a/mylis/analysis_options.yaml b/mylis/analysis_options.yaml deleted file mode 100644 index 0d290213..00000000 --- a/mylis/analysis_options.yaml +++ /dev/null @@ -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 diff --git a/mylis/android/.gitignore b/mylis/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/mylis/android/.gitignore +++ /dev/null @@ -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 diff --git a/mylis/android/app/build.gradle.kts b/mylis/android/app/build.gradle.kts deleted file mode 100644 index 261b6908..00000000 --- a/mylis/android/app/build.gradle.kts +++ /dev/null @@ -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 = "../.." -} diff --git a/mylis/android/app/google-services.json b/mylis/android/app/google-services.json deleted file mode 100644 index 2b25f375..00000000 --- a/mylis/android/app/google-services.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/mylis/android/app/src/debug/AndroidManifest.xml b/mylis/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/mylis/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/mylis/android/app/src/main/AndroidManifest.xml b/mylis/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index d729c66c..00000000 --- a/mylis/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/android/app/src/main/kotlin/com/duidev/mylis/MainActivity.kt b/mylis/android/app/src/main/kotlin/com/duidev/mylis/MainActivity.kt deleted file mode 100644 index 067728bf..00000000 --- a/mylis/android/app/src/main/kotlin/com/duidev/mylis/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.duidev.mylis - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/mylis/android/app/src/main/res/drawable-v21/launch_background.xml b/mylis/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/mylis/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/mylis/android/app/src/main/res/drawable/launch_background.xml b/mylis/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/mylis/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/mylis/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mylis/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 7d101b33..00000000 Binary files a/mylis/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/mylis/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mylis/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 1d63adaf..00000000 Binary files a/mylis/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/mylis/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mylis/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d355fb43..00000000 Binary files a/mylis/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/mylis/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mylis/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index ecb7bedb..00000000 Binary files a/mylis/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/mylis/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mylis/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index e98d7082..00000000 Binary files a/mylis/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/mylis/android/app/src/main/res/values-night/styles.xml b/mylis/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/mylis/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/mylis/android/app/src/main/res/values/styles.xml b/mylis/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/mylis/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/mylis/android/app/src/profile/AndroidManifest.xml b/mylis/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/mylis/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/mylis/android/build.gradle.kts b/mylis/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/mylis/android/build.gradle.kts +++ /dev/null @@ -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("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/mylis/android/build/reports/problems/problems-report.html b/mylis/android/build/reports/problems/problems-report.html deleted file mode 100644 index 8cbf7496..00000000 --- a/mylis/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/mylis/android/gradle.properties b/mylis/android/gradle.properties deleted file mode 100644 index e96108cf..00000000 --- a/mylis/android/gradle.properties +++ /dev/null @@ -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 diff --git a/mylis/android/gradle/wrapper/gradle-wrapper.properties b/mylis/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bfb..00000000 --- a/mylis/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -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 diff --git a/mylis/android/settings.gradle.kts b/mylis/android/settings.gradle.kts deleted file mode 100644 index c21f0c5b..00000000 --- a/mylis/android/settings.gradle.kts +++ /dev/null @@ -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") diff --git a/mylis/assets/branding/banner.png b/mylis/assets/branding/banner.png deleted file mode 100644 index 8dc2dbc4..00000000 Binary files a/mylis/assets/branding/banner.png and /dev/null differ diff --git a/mylis/assets/branding/logo.png b/mylis/assets/branding/logo.png deleted file mode 100644 index 740ef065..00000000 Binary files a/mylis/assets/branding/logo.png and /dev/null differ diff --git a/mylis/assets/branding/logo_rssa.png b/mylis/assets/branding/logo_rssa.png deleted file mode 100644 index 4af06927..00000000 Binary files a/mylis/assets/branding/logo_rssa.png and /dev/null differ diff --git a/mylis/ios/.gitignore b/mylis/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/mylis/ios/.gitignore +++ /dev/null @@ -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 diff --git a/mylis/ios/Flutter/AppFrameworkInfo.plist b/mylis/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/mylis/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/mylis/ios/Flutter/Debug.xcconfig b/mylis/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/mylis/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/mylis/ios/Flutter/Release.xcconfig b/mylis/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/mylis/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/mylis/ios/Runner.xcodeproj/project.pbxproj b/mylis/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 4b82eba1..00000000 --- a/mylis/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -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 = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 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 = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 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 = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* 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 = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 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 = ""; - }; -/* 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 = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* 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 */; -} diff --git a/mylis/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mylis/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/mylis/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/mylis/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/mylis/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mylis/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c3fedb29..00000000 --- a/mylis/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/ios/Runner.xcworkspace/contents.xcworkspacedata b/mylis/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/mylis/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mylis/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mylis/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/mylis/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mylis/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mylis/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/mylis/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/mylis/ios/Runner/AppDelegate.swift b/mylis/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/mylis/ios/Runner/AppDelegate.swift +++ /dev/null @@ -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) - } -} diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 2cee7135..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7641cc29..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 19831231..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 3897d99d..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index d17770df..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 0dd1b01e..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index d8eb6441..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 19831231..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 1bbe1398..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index b73b6ea7..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index b73b6ea7..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index d719f10f..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 579af1db..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8d646082..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index c4a1d10e..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/mylis/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/mylis/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mylis/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/mylis/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/ios/Runner/Base.lproj/Main.storyboard b/mylis/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/mylis/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/ios/Runner/Info.plist b/mylis/ios/Runner/Info.plist deleted file mode 100644 index 3bed2f55..00000000 --- a/mylis/ios/Runner/Info.plist +++ /dev/null @@ -1,72 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - MyLIS - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - MyLIS - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSCameraUsageDescription - MyLIS memakai kamera untuk scan barcode sampel. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/mylis/ios/Runner/Runner-Bridging-Header.h b/mylis/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/mylis/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/mylis/ios/Runner/SceneDelegate.swift b/mylis/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/mylis/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/mylis/ios/RunnerTests/RunnerTests.swift b/mylis/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/mylis/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -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. - } - -} diff --git a/mylis/lib/app/app.dart b/mylis/lib/app/app.dart deleted file mode 100644 index e344e6b6..00000000 --- a/mylis/lib/app/app.dart +++ /dev/null @@ -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(), - ); - } -} diff --git a/mylis/lib/data/expertise_data.dart b/mylis/lib/data/expertise_data.dart deleted file mode 100644 index 7ba1b7df..00000000 --- a/mylis/lib/data/expertise_data.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of '../app/app.dart'; - -const Set _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'; -} diff --git a/mylis/lib/main.dart b/mylis/lib/main.dart deleted file mode 100644 index f41e1e40..00000000 --- a/mylis/lib/main.dart +++ /dev/null @@ -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; - }; - } -} diff --git a/mylis/lib/providers/session_provider.dart b/mylis/lib/providers/session_provider.dart deleted file mode 100644 index f3ca547b..00000000 --- a/mylis/lib/providers/session_provider.dart +++ /dev/null @@ -1,35 +0,0 @@ -part of '../app/app.dart'; - -class SessionGate extends StatefulWidget { - const SessionGate({super.key}); - - @override - State createState() => _SessionGateState(); -} - -class _SessionGateState extends State { - late final Future _sessionFuture; - - @override - void initState() { - super.initState(); - _sessionFuture = SessionStore.session(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - 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); - }, - ); - } -} diff --git a/mylis/lib/screens/dashboard/barcode_scanner_screen.dart b/mylis/lib/screens/dashboard/barcode_scanner_screen.dart deleted file mode 100644 index d919bf2e..00000000 --- a/mylis/lib/screens/dashboard/barcode_scanner_screen.dart +++ /dev/null @@ -1,169 +0,0 @@ -part of '../../app/app.dart'; - -class BarcodeScannerScreen extends StatefulWidget { - const BarcodeScannerScreen({super.key}); - - @override - State createState() => _BarcodeScannerScreenState(); -} - -class _BarcodeScannerScreenState extends State { - 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'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mylis/lib/screens/dashboard/critical_values_screen.dart b/mylis/lib/screens/dashboard/critical_values_screen.dart deleted file mode 100644 index 79492044..00000000 --- a/mylis/lib/screens/dashboard/critical_values_screen.dart +++ /dev/null @@ -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 createState() => _CriticalValuesScreenState(); -} - -class _CriticalValuesScreenState extends State { - late final ApiClient _api; - late Future> _future; - - @override - void initState() { - super.initState(); - _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); - _future = _load(); - } - - Future> _load() { - return _api.get('api/mobile/critical-values'); - } - - void _reload() { - setState(() { - _future = _load(); - }); - } - - Future _markRead(Map item) async { - final method = ValueNotifier(''); - final recipient = TextEditingController(); - final reason = TextEditingController(); - - await showModalBottomSheet( - 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( - 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( - 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>( - 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 items; - final String emptyText; - final ValueChanged>? 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 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 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 '-'; - } -} diff --git a/mylis/lib/screens/dashboard/dashboard_screen.dart b/mylis/lib/screens/dashboard/dashboard_screen.dart deleted file mode 100644 index 22e1a8d4..00000000 --- a/mylis/lib/screens/dashboard/dashboard_screen.dart +++ /dev/null @@ -1,1436 +0,0 @@ -part of '../../app/app.dart'; - -class DashboardScreen extends StatefulWidget { - const DashboardScreen({ - super.key, - required this.token, - required this.baseUrl, - }); - - final String token; - final String baseUrl; - - @override - State createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends State { - late final ApiClient _api; - late Future> _future; - final _homeSearch = TextEditingController(); - int _selectedTab = 0; - - @override - void initState() { - super.initState(); - _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); - _future = _api.get('api/mobile/dashboard'); - } - - @override - void dispose() { - _homeSearch.dispose(); - super.dispose(); - } - - void _reload() { - setState(() { - _future = _api.get('api/mobile/dashboard'); - }); - } - - Future _logout() async { - await SessionStore.clear(); - if (!mounted) { - return; - } - Navigator.of( - context, - ).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen())); - } - - Future _searchAndOpenDetail(String query) async { - final keyword = query.trim(); - if (keyword.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Masukkan kata kunci sampel.')), - ); - return; - } - - try { - final data = await _api.get('api/mobile/examinations/search', { - 'search': keyword, - }); - final item = asMap(data['item']); - final id = (item['id'] as num?)?.toInt(); - if (id == null) { - throw ApiException('Data pemeriksaan tidak ditemukan.'); - } - if (!mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ExaminationDetailScreen( - token: widget.token, - baseUrl: widget.baseUrl, - id: id, - ), - ), - ); - } on ApiException catch (error) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error.message))); - } - } - - Future _scanAndSearch() async { - final result = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), - ); - if (result == null || result.trim().isEmpty) { - return; - } - _homeSearch.text = result.trim(); - await _searchAndOpenDetail(result); - } - - void _showSearchSheet() { - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (context) => SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB( - 18, - 0, - 18, - MediaQuery.viewInsetsOf(context).bottom + 18, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Cari Sampel', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900), - ), - const SizedBox(height: 12), - TextField( - controller: _homeSearch, - autofocus: true, - textInputAction: TextInputAction.search, - onSubmitted: (value) { - Navigator.of(context).pop(); - _searchAndOpenDetail(value); - }, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - labelText: 'No. Sampel / No. RM / Nama Pasien', - border: const OutlineInputBorder(), - suffixIcon: IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await _scanAndSearch(); - }, - icon: const Icon(Icons.qr_code_scanner_rounded), - tooltip: 'Scan barcode', - ), - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () { - Navigator.of(context).pop(); - _searchAndOpenDetail(_homeSearch.text); - }, - icon: const Icon(Icons.manage_search_rounded), - label: const Text('Cari dan Buka Detail'), - ), - ], - ), - ), - ), - ); - } - - void _openBook(Map book) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ExaminationListScreen( - token: widget.token, - baseUrl: widget.baseUrl, - master: book['master']?.toString() ?? 'buku0', - title: book['label']?.toString() ?? 'Pemeriksaan', - ), - ), - ); - } - - void _openEws(List warnings) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EwsScreen( - warnings: warnings, - token: widget.token, - baseUrl: widget.baseUrl, - ), - ), - ); - } - - void _openInitialWork(Map user, int notificationCount) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => InitialWorkScreen( - token: widget.token, - baseUrl: widget.baseUrl, - user: user, - notificationCount: notificationCount, - ), - ), - ); - } - - void _openSampleGroups( - List books, - Map user, - int notificationCount, - ) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SampleGroupsScreen( - books: books, - token: widget.token, - baseUrl: widget.baseUrl, - user: user, - notificationCount: notificationCount, - ), - ), - ); - } - - void _openAllSpecimens(Map user, int notificationCount) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SpecimenRegisterScreen( - token: widget.token, - baseUrl: widget.baseUrl, - master: 'ALL', - title: 'Semua Spesimen', - total: 0, - user: user, - notificationCount: notificationCount, - ), - ), - ); - } - - Future _openCriticalValues() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), - ), - ); - if (mounted) { - _reload(); - } - } - - void _showProfile(Map user) { - showModalBottomSheet( - 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: () { - Navigator.of(context).pop(); - _logout(); - }, - icon: const Icon(Icons.logout), - label: const Text('Logout'), - ), - ], - ), - ), - ), - ); - } - - void _handleBottomNav(int value, Map user) { - setState(() => _selectedTab = value); - if (value == 1) { - _showSearchSheet(); - } else if (value == 2) { - _showProfile(user); - setState(() => _selectedTab = 0); - } - } - - 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: RefreshIndicator( - onRefresh: () async => _reload(), - child: FutureBuilder>( - 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 user = asMap(data['user']); - final summary = asMap(data['summary']); - final warnings = asList(data['early_warning_groups']); - final books = asList(data['books']); - final notificationCount = - (summary['criticalNotificationCount'] as num? ?? 0).toInt(); - return ListView( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), - children: [ - HomeTopBar( - user: user, - notificationCount: notificationCount, - onNotifications: _openCriticalValues, - onProfile: () => _showProfile(user), - ), - const SizedBox(height: 22), - SampleSearchPanel( - controller: _homeSearch, - onSearch: () => _searchAndOpenDetail(_homeSearch.text), - onScan: _scanAndSearch, - ), - const SizedBox(height: 24), - const HomeSectionTitle(title: 'Menu Utama'), - const SizedBox(height: 12), - MainMenuGrid( - books: books, - onEwsTap: () => _openEws(warnings), - onInitialWorkTap: () => - _openInitialWork(user, notificationCount), - onSpecimenTap: () => - _openAllSpecimens(user, notificationCount), - onBookTap: _openBook, - ), - const SizedBox(height: 24), - EwsSummaryPanel( - warnings: warnings, - token: widget.token, - baseUrl: widget.baseUrl, - ), - const SizedBox(height: 18), - DetailSamplePanel( - onTap: () => - _openSampleGroups(books, user, notificationCount), - total: summary['antrian_hari_ini'], - ), - const SizedBox(height: 12), - ], - ); - }, - ), - ), - bottomNavigationBar: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - final user = asMap(snapshot.data?['user']); - return NavigationBar( - selectedIndex: _selectedTab, - onDestinationSelected: (value) => _handleBottomNav(value, user), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Beranda', - ), - NavigationDestination( - icon: Icon(Icons.search), - label: 'Cari sampel', - ), - NavigationDestination( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: 'Profil', - ), - ], - ); - }, - ), - ); - } -} - -class HomeTopBar extends StatelessWidget { - const HomeTopBar({ - super.key, - required this.user, - required this.notificationCount, - required this.onNotifications, - required this.onProfile, - }); - - final Map user; - final int notificationCount; - final VoidCallback onNotifications; - final VoidCallback onProfile; - - @override - Widget build(BuildContext context) { - final initials = _initials(user['nama']?.toString() ?? 'SP'); - return SafeArea( - bottom: false, - child: Row( - children: [ - Container( - width: 66, - height: 66, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(18), - ), - child: Image.asset(kAppLogoAsset, fit: BoxFit.contain), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Mikrobiology Laboratory Information System', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: const Color(0xFF47517A), - ), - ), - ], - ), - ), - Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - onPressed: onNotifications, - icon: const Icon(Icons.notifications_none_rounded, size: 31), - 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: 8), - InkWell( - borderRadius: BorderRadius.circular(28), - onTap: onProfile, - child: Row( - children: [ - Container( - width: 56, - height: 56, - alignment: Alignment.center, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Color(0xFF10B981), Color(0xFF06B6D4)], - ), - ), - child: Text( - initials, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 20, - ), - ), - ), - const Icon(Icons.keyboard_arrow_down_rounded), - ], - ), - ), - ], - ), - ); - } - - 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 SampleSearchPanel extends StatelessWidget { - const SampleSearchPanel({ - super.key, - required this.controller, - required this.onSearch, - required this.onScan, - }); - - final TextEditingController controller; - final VoidCallback onSearch; - final VoidCallback onScan; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.manage_search_rounded, - color: Color(0xFF0B7BFF), - size: 38, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Cari sampel dengan cepat', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF080D3D), - ), - ), - const SizedBox(height: 3), - Text( - 'Cari dan pantau status sampel', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFF47517A), - ), - ), - ], - ), - ), - const Icon( - Icons.keyboard_arrow_up_rounded, - color: Color(0xFF0B7BFF), - ), - ], - ), - const SizedBox(height: 18), - Text( - 'Masukkan kata kunci', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - color: const Color(0xFF47517A), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - textInputAction: TextInputAction.search, - onSubmitted: (_) => onSearch(), - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search), - hintText: 'Ketik No. Sampel, No. RM, Nama Pasien', - border: OutlineInputBorder(), - isDense: true, - ), - ), - ), - const SizedBox(width: 10), - IconButton.outlined( - onPressed: onScan, - icon: const Icon(Icons.qr_code_scanner_rounded), - tooltip: 'Scan barcode', - ), - const SizedBox(width: 10), - SizedBox( - width: 88, - child: FilledButton( - onPressed: onSearch, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text('Cari'), - ), - ), - ], - ), - ], - ), - ); - } -} - -class HomeSectionTitle extends StatelessWidget { - const HomeSectionTitle({super.key, required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - return Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF080D3D), - ), - ); - } -} - -class MainMenuGrid extends StatelessWidget { - const MainMenuGrid({ - super.key, - required this.books, - required this.onEwsTap, - required this.onInitialWorkTap, - required this.onSpecimenTap, - required this.onBookTap, - }); - - final List books; - final VoidCallback onEwsTap; - final VoidCallback onInitialWorkTap; - final VoidCallback onSpecimenTap; - final ValueChanged> onBookTap; - - @override - Widget build(BuildContext context) { - final items = [ - _HomeMenuItem( - title: 'EWS', - subtitle: 'Early Warning Score', - icon: Icons.track_changes_rounded, - color: const Color(0xFF0B7BFF), - onTap: onEwsTap, - ), - _HomeMenuItem( - title: 'Pengerjaan Awal', - subtitle: 'Validasi & Verifikasi', - icon: Icons.check_circle_outline_rounded, - color: const Color(0xFF11A85B), - onTap: onInitialWorkTap, - ), - _HomeMenuItem( - title: 'Spesimen', - subtitle: 'Catatan & Register', - icon: Icons.menu_book_rounded, - color: const Color(0xFFFF6B00), - onTap: onSpecimenTap, - ), - ]; - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: items.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.68, - ), - itemBuilder: (context, index) => _HomeMenuCard(item: items[index]), - ); - } -} - -class _HomeMenuCard extends StatelessWidget { - const _HomeMenuCard({required this.item}); - - final _HomeMenuItem item; - - @override - Widget build(BuildContext context) { - return InkWell( - borderRadius: BorderRadius.circular(14), - onTap: item.onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: const Color(0xFFE1E8F5)), - borderRadius: BorderRadius.circular(14), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: item.color.withValues(alpha: 0.13), - borderRadius: BorderRadius.circular(16), - ), - child: Icon(item.icon, color: item.color, size: 29), - ), - const SizedBox(height: 8), - Text( - item.title, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w900, - color: item.color, - height: 1.05, - ), - ), - const SizedBox(height: 6), - Text( - item.subtitle, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: const Color(0xFF47517A), - fontWeight: FontWeight.w600, - fontSize: 11, - height: 1.2, - ), - ), - ], - ), - ), - ); - } -} - -class _HomeMenuItem { - const _HomeMenuItem({ - required this.title, - required this.subtitle, - required this.icon, - required this.color, - required this.onTap, - }); - - final String title; - final String subtitle; - final IconData icon; - final Color color; - final VoidCallback onTap; -} - -class EwsSummaryPanel extends StatelessWidget { - const EwsSummaryPanel({ - super.key, - required this.warnings, - required this.token, - required this.baseUrl, - }); - - final List warnings; - final String token; - final String baseUrl; - - @override - Widget build(BuildContext context) { - final totalTypes = warnings.length; - final rows = warnings.take(3).map((item) => asMap(item)).toList(); - return Container( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: const Color(0xFFE1E8F5)), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 12, 14), - child: Row( - children: [ - Expanded( - child: Text( - 'EWS - Early Warning System', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF0B7BFF), - ), - ), - ), - const Icon( - Icons.keyboard_arrow_up_rounded, - color: Color(0xFF0B7BFF), - ), - ], - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: Text( - 'Ringkasan per Jenis Spesimen', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF080D3D), - ), - ), - ), - Text( - 'Total $totalTypes jenis spesimen', - style: const TextStyle( - color: Color(0xFF47517A), - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (rows.isEmpty) - const EmptyPanel(text: 'Tidak ada data Early Warning.') - else - ...rows.map( - (row) => - EwsSummaryRow(group: row, token: token, baseUrl: baseUrl), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Wrap( - spacing: 16, - runSpacing: 8, - children: const [ - _LegendDot( - color: Color(0xFFFF1D25), - text: 'Melewati Target (TAT > Target)', - ), - _LegendDot(color: Color(0xFFFF8A00), text: 'Mendekati Target'), - ], - ), - ), - ], - ), - ); - } -} - -class EwsSummaryRow extends StatelessWidget { - const EwsSummaryRow({ - super.key, - required this.group, - required this.token, - required this.baseUrl, - }); - - final Map group; - final String token; - final String baseUrl; - - @override - Widget build(BuildContext context) { - final items = asList(group['items']); - final total = (group['total'] as num? ?? items.length).toInt(); - final late = _countByText(items, ['lewat', 'melewati', 'target']); - final near = total - late > 0 ? total - late : 0; - final title = group['subpoli']?.toString().isNotEmpty == true - ? group['subpoli'].toString() - : 'Tanpa Subpoli'; - final subtitle = items.isEmpty - ? 'Belum ada sampel' - : asMap(items.first)['reques']?.toString() ?? 'Pemeriksaan aktif'; - return InkWell( - onTap: () { - if (items.isEmpty) return; - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => ListView( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - children: [ - Text( - title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900), - ), - const SizedBox(height: 10), - ...items.map( - (item) => ExaminationCompactTile( - item: asMap(item), - token: token, - baseUrl: baseUrl, - ), - ), - ], - ), - ); - }, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - child: Row( - children: [ - _SpecimenIcon(title: title), - const SizedBox(width: 12), - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title.toUpperCase(), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFF080D3D), - fontWeight: FontWeight.w900, - ), - ), - const SizedBox(height: 5), - Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFF47517A), - fontWeight: FontWeight.w600, - height: 1.35, - ), - ), - ], - ), - ), - _EwsCount( - label: 'Melewati', - value: late, - color: const Color(0xFFFF1D25), - ), - _EwsCount( - label: 'Mendekati', - value: near, - color: const Color(0xFFFF7A00), - ), - _EwsCount( - label: 'Total', - value: total, - color: const Color(0xFF47517A), - ), - const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)), - ], - ), - ), - ); - } - - int _countByText(List items, List keywords) { - var count = 0; - for (final item in items) { - final text = asMap(item).values.join(' ').toLowerCase(); - if (keywords.any(text.contains)) { - count += 1; - } - } - return count == 0 && items.isNotEmpty ? items.length : count; - } -} - -class _SpecimenIcon extends StatelessWidget { - const _SpecimenIcon({required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - final lower = title.toLowerCase(); - final color = lower.contains('darah') - ? const Color(0xFF11A85B) - : lower.contains('sputum') - ? const Color(0xFF06A6D7) - : const Color(0xFF8B2BEF); - final icon = lower.contains('darah') - ? Icons.water_drop_rounded - : lower.contains('sputum') - ? Icons.science_rounded - : Icons.biotech_rounded; - return Container( - width: 58, - height: 58, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: Icon(icon, color: color, size: 30), - ); - } -} - -class _EwsCount extends StatelessWidget { - const _EwsCount({ - required this.label, - required this.value, - required this.color, - }); - - final String label; - final int value; - final Color color; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 66, - child: Column( - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 6), - Text( - '$value', - style: TextStyle( - color: color, - fontSize: 24, - fontWeight: FontWeight.w900, - ), - ), - ], - ), - ); - } -} - -class _LegendDot extends StatelessWidget { - const _LegendDot({required this.color, required this.text}); - - final Color color; - final String text; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 8), - Text( - text, - style: const TextStyle( - color: Color(0xFF47517A), - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} - -class DetailSamplePanel extends StatelessWidget { - const DetailSamplePanel({ - super.key, - required this.onTap, - required this.total, - }); - - final VoidCallback onTap; - final Object? total; - - @override - Widget build(BuildContext context) { - return InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: const Color(0xFFE1E8F5)), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Detail Sampel', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF080D3D), - ), - ), - const SizedBox(height: 10), - Text( - 'Pilih jenis spesimen untuk melihat daftar sampel yang mendekati atau melewati target TAT.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFF47517A), - height: 1.5, - ), - ), - const SizedBox(height: 8), - BadgeLabel( - text: '${total ?? 0} sampel hari ini', - color: const Color(0xFF0B7BFF), - ), - ], - ), - ), - const SizedBox(width: 12), - Container( - width: 86, - height: 86, - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(18), - ), - child: const Icon( - Icons.find_in_page_outlined, - color: Color(0xFF0B7BFF), - size: 52, - ), - ), - ], - ), - ), - ); - } -} - -class HeaderPanel extends StatelessWidget { - const HeaderPanel({super.key, required this.user, required this.summary}); - - final Map user; - final Map summary; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F766E), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 54, - height: 54, - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: Image.asset(kHospitalLogoAsset, fit: BoxFit.contain), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - kAppShortName, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w800, - ), - ), - const Text( - kHospitalName, - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 14), - Text( - user['nama']?.toString() ?? '-', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - Text( - user['previlage']?.toString() ?? '-', - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: SummaryItem(label: 'PPDS', value: summary['total_ppds']), - ), - Expanded( - child: SummaryItem( - label: 'Verifikasi', - value: summary['butuh_verifikasi'], - ), - ), - Expanded( - child: SummaryItem( - label: 'Hari Ini', - value: summary['antrian_hari_ini'], - ), - ), - ], - ), - ], - ), - ); - } -} - -class SummaryItem extends StatelessWidget { - const SummaryItem({super.key, required this.label, required this.value}); - final String label; - final Object? value; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${value ?? 0}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w800, - ), - ), - Text( - label, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ); - } -} - -class SectionTitle extends StatelessWidget { - const SectionTitle({super.key, required this.title, this.actionLabel}); - final String title; - final String? actionLabel; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), - ), - ), - if (actionLabel != null) - BadgeLabel(text: actionLabel!, color: const Color(0xFFDC2626)), - ], - ), - ); - } -} - -class EarlyWarningGroupCard extends StatelessWidget { - const EarlyWarningGroupCard({ - super.key, - required this.group, - required this.token, - required this.baseUrl, - }); - - final Map group; - final String token; - final String baseUrl; - - @override - Widget build(BuildContext context) { - final items = asList(group['items']); - return Card( - margin: const EdgeInsets.only(bottom: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: ExpansionTile( - leading: const Icon( - Icons.warning_amber_rounded, - color: Color(0xFFDC2626), - ), - title: Text( - group['subpoli']?.toString() ?? 'Tanpa Subpoli', - style: const TextStyle(fontWeight: FontWeight.w700), - ), - subtitle: Text('${group['total'] ?? items.length} kasus warning'), - children: items - .map( - (item) => ExaminationCompactTile( - item: asMap(item), - token: token, - baseUrl: baseUrl, - ), - ) - .toList(), - ), - ); - } -} - -class BookTile extends StatelessWidget { - const BookTile({ - super.key, - required this.book, - required this.token, - required this.baseUrl, - this.detailBuilder, - }); - - final Map book; - final String token; - final String baseUrl; - final Widget Function(Map book)? detailBuilder; - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Color(0xFFE0F2F1), - child: Icon(Icons.science_outlined, color: Color(0xFF0F766E)), - ), - title: Text(book['label']?.toString() ?? '-'), - subtitle: Text('${book['total'] ?? 0} pemeriksaan aktif'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - detailBuilder?.call(book) ?? - ExaminationListScreen( - token: token, - baseUrl: baseUrl, - master: book['master'].toString(), - title: book['label']?.toString() ?? 'Pemeriksaan', - ), - ), - ); - }, - ), - ); - } -} diff --git a/mylis/lib/screens/dashboard/ews_screen.dart b/mylis/lib/screens/dashboard/ews_screen.dart deleted file mode 100644 index 40a8ea38..00000000 --- a/mylis/lib/screens/dashboard/ews_screen.dart +++ /dev/null @@ -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 warnings; - final String token; - final String baseUrl; - - @override - Widget build(BuildContext context) { - final total = warnings.fold( - 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, - ), - ), - ], - ), - ); - } -} diff --git a/mylis/lib/screens/dashboard/initial_work_screen.dart b/mylis/lib/screens/dashboard/initial_work_screen.dart deleted file mode 100644 index e2f41081..00000000 --- a/mylis/lib/screens/dashboard/initial_work_screen.dart +++ /dev/null @@ -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 user; - final int notificationCount; - - @override - State createState() => _InitialWorkScreenState(); -} - -class _InitialWorkScreenState extends State { - late final ApiClient _api; - late Future> _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> _load() { - return _api.get('api/mobile/initial-work/samples'); - } - - void _reload() { - setState(() { - _future = _load(); - }); - } - - void _onSearchChanged() { - if (!mounted) return; - setState(() { - _page = 0; - }); - } - - Future _openCriticalValues() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), - ), - ); - } - - void _showProfile() { - showModalBottomSheet( - 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 _showActions(Map item) async { - await showModalBottomSheet( - 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 _showAcceptDialog(Map item) async { - await showDialog( - context: context, - builder: (context) => _AcceptInitialWorkDialog( - item: item, - onSubmit: (payload) => _accept(item, payload), - ), - ); - } - - Future _accept( - Map item, - Map 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 _confirmReject(Map item) async { - final confirmed = await showDialog( - 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 _reject(Map 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> _filtered(List 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().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>( - 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 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 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 item; - final Future Function(Map 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 _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( - 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( - 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 _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); - } - } -} diff --git a/mylis/lib/screens/dashboard/sample_groups_screen.dart b/mylis/lib/screens/dashboard/sample_groups_screen.dart deleted file mode 100644 index c063d9f1..00000000 --- a/mylis/lib/screens/dashboard/sample_groups_screen.dart +++ /dev/null @@ -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 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 books; - final String token; - final String baseUrl; - final Map user; - final int notificationCount; - - @override - Widget build(BuildContext context) { - final total = books.fold( - 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 user) { - showModalBottomSheet( - 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 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)), - ], - ), - ), - ), - ); - } -} diff --git a/mylis/lib/screens/dashboard/specimen_register_screen.dart b/mylis/lib/screens/dashboard/specimen_register_screen.dart deleted file mode 100644 index 87f0e39b..00000000 --- a/mylis/lib/screens/dashboard/specimen_register_screen.dart +++ /dev/null @@ -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 user; - final int notificationCount; - - @override - State createState() => _SpecimenRegisterScreenState(); -} - -class _SpecimenRegisterScreenState extends State { - late final ApiClient _api; - late Future> _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> _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 _openCriticalValues() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), - ), - ); - } - - void _showProfile() { - showModalBottomSheet( - 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>( - 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 _filtered(List 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().join(' ').toLowerCase().contains(query); - return specimenOk && statusOk && searchOk; - }).toList(); - } - - List<_FilterOption> _specimenOptions(List items) { - final values = {}; - 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 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 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 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 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( - 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 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'), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mylis/lib/screens/examinations/examination_detail_screen.dart b/mylis/lib/screens/examinations/examination_detail_screen.dart deleted file mode 100644 index 1b7caff0..00000000 --- a/mylis/lib/screens/examinations/examination_detail_screen.dart +++ /dev/null @@ -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 createState() => - _ExaminationDetailScreenState(); -} - -class _ExaminationDetailScreenState extends State { - late final ApiClient _api; - late Future> _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 _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 _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>( - 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 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 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'), - ), - ], - ], - ), - ); - } -} diff --git a/mylis/lib/screens/examinations/examination_list_screen.dart b/mylis/lib/screens/examinations/examination_list_screen.dart deleted file mode 100644 index 6efc1acd..00000000 --- a/mylis/lib/screens/examinations/examination_list_screen.dart +++ /dev/null @@ -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 createState() => _ExaminationListScreenState(); -} - -class _ExaminationListScreenState extends State { - late final ApiClient _api; - late Future> _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> _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>( - 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 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 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 _openDetail( - BuildContext context, - Map item, - String token, - String baseUrl, -) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ExaminationDetailScreen( - token: token, - baseUrl: baseUrl, - id: (item['id'] as num).toInt(), - ), - ), - ); -} diff --git a/mylis/lib/screens/expertise/cci_expertise_wizard.dart b/mylis/lib/screens/expertise/cci_expertise_wizard.dart deleted file mode 100644 index d1d13aa4..00000000 --- a/mylis/lib/screens/expertise/cci_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map optionSets; - final Map textControllers; - final Map selectValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => _CciExpertiseWizardState(); -} - -class _CciExpertiseWizardState extends State { - 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 _options(String key, List 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 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( - 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 item; - final Map user; - final Map staffOptions; - final Map 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 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( - 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 _sirOptions = [ - 'ESBL', - 'MRSA', - 'MDR', - 'XDR', - 'PDR', - 'Carbapenem Resistant', - 'MDR Carbapenem Resistant', - 'XDR Carbapenem Resistant', -]; - -const List _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', -]; diff --git a/mylis/lib/screens/expertise/covid_expertise_wizard.dart b/mylis/lib/screens/expertise/covid_expertise_wizard.dart deleted file mode 100644 index 9a23748b..00000000 --- a/mylis/lib/screens/expertise/covid_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map textControllers; - final Map selectValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => _CovidExpertiseWizardState(); -} - -class _CovidExpertiseWizardState extends State { - 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 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 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( - 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 _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', - ), -]; diff --git a/mylis/lib/screens/expertise/expertise_form.dart b/mylis/lib/screens/expertise/expertise_form.dart deleted file mode 100644 index 76af8c5b..00000000 --- a/mylis/lib/screens/expertise/expertise_form.dart +++ /dev/null @@ -1,2465 +0,0 @@ -part of '../../app/app.dart'; - -class ExpertiseWizardShell extends StatelessWidget { - const ExpertiseWizardShell({ - super.key, - required this.currentStep, - required this.steps, - required this.onStepChanged, - }); - - final int currentStep; - final List steps; - final ValueChanged onStepChanged; - - @override - Widget build(BuildContext context) { - final safeStep = currentStep.clamp(0, steps.length - 1); - final step = steps[safeStep]; - final title = _stepTitle(step.title); - final canPrevious = safeStep > 0; - final canNext = safeStep < steps.length - 1; - - return Column( - children: [ - Expanded( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 10), - child: _WizardHeader( - currentStep: safeStep, - steps: steps, - onStepChanged: onStepChanged, - ), - ), - ), - SliverToBoxAdapter( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - child: Padding( - key: ValueKey(safeStep), - padding: const EdgeInsets.fromLTRB(18, 0, 18, 18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF080D3D), - ), - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.topCenter, - child: step.content, - ), - ], - ), - ), - ), - ), - ], - ), - ), - _WizardBottomToolbar( - currentStep: safeStep, - totalSteps: steps.length, - canPrevious: canPrevious, - canNext: canNext, - onPrevious: () => onStepChanged(safeStep - 1), - onNext: () => onStepChanged(safeStep + 1), - ), - ], - ); - } - - String _stepTitle(Widget title) { - if (title is Text) { - final data = title.data; - if (data != null && data.isNotEmpty) { - return data; - } - } - return 'Langkah ${currentStep + 1}'; - } -} - -class _WizardHeader extends StatelessWidget { - const _WizardHeader({ - required this.currentStep, - required this.steps, - required this.onStepChanged, - }); - - final int currentStep; - final List steps; - final ValueChanged onStepChanged; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 50, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: steps.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final selected = index == currentStep; - final title = _stepTitle(steps[index].title, index); - return ChoiceChip( - selected: selected, - label: Text('${index + 1}. $title'), - onSelected: (_) => onStepChanged(index), - showCheckmark: false, - visualDensity: VisualDensity.compact, - selectedColor: const Color(0xFFE0F2F1), - backgroundColor: Colors.white, - side: BorderSide( - color: selected - ? const Color(0xFF0F766E) - : const Color(0xFFE1E8F5), - ), - labelStyle: TextStyle( - color: selected - ? const Color(0xFF0F766E) - : const Color(0xFF47517A), - fontWeight: FontWeight.w800, - ), - ); - }, - ), - ); - } - - String _stepTitle(Widget title, int index) { - if (title is Text) { - final data = title.data; - if (data != null && data.isNotEmpty) { - return data; - } - } - return 'Langkah ${index + 1}'; - } -} - -class _WizardBottomToolbar extends StatelessWidget { - const _WizardBottomToolbar({ - required this.currentStep, - required this.totalSteps, - required this.canPrevious, - required this.canNext, - required this.onPrevious, - required this.onNext, - }); - - final int currentStep; - final int totalSteps; - final bool canPrevious; - final bool canNext; - final VoidCallback onPrevious; - final VoidCallback onNext; - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Container( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), - decoration: const BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), - ), - child: Row( - children: [ - OutlinedButton.icon( - onPressed: canPrevious ? onPrevious : null, - icon: const Icon(Icons.chevron_left_rounded), - label: const Text('Previous'), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - '${currentStep + 1}/$totalSteps', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - color: Colors.black54, - ), - ), - ), - const SizedBox(width: 12), - FilledButton.icon( - onPressed: canNext ? onNext : null, - icon: const Icon(Icons.chevron_right_rounded), - label: const Text('Next'), - ), - ], - ), - ), - ); - } -} - -class CollapsibleExpertiseSection extends StatefulWidget { - const CollapsibleExpertiseSection({ - super.key, - required this.index, - required this.title, - required this.children, - this.completed = false, - this.initiallyExpanded = false, - this.color, - }); - - final int index; - final String title; - final List children; - final bool completed; - final bool initiallyExpanded; - final Color? color; - - @override - State createState() => - _CollapsibleExpertiseSectionState(); -} - -class _CollapsibleExpertiseSectionState - extends State { - late bool _expanded; - - @override - void initState() { - super.initState(); - _expanded = widget.initiallyExpanded; - } - - @override - Widget build(BuildContext context) { - final color = widget.color ?? _sectionColor(widget.index); - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: const Color(0xFFE1E8F5)), - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: const Color(0xFF2563EB).withValues(alpha: 0.04), - blurRadius: 14, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 44, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.11), - shape: BoxShape.circle, - ), - child: Text( - '${widget.index}.', - style: TextStyle( - color: color, - fontSize: 20, - fontWeight: FontWeight.w900, - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Text( - widget.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: const Color(0xFF080D3D), - fontWeight: FontWeight.w900, - ), - ), - ), - if (widget.completed) ...[ - Container( - width: 34, - height: 34, - decoration: const BoxDecoration( - color: Color(0xFF0F766E), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check_rounded, - color: Colors.white, - size: 22, - ), - ), - const SizedBox(width: 10), - ], - Icon( - _expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: const Color(0xFF0F172A), - size: 30, - ), - ], - ), - ), - ), - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.children, - ), - ), - crossFadeState: _expanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 180), - ), - ], - ), - ); - } - - Color _sectionColor(int index) { - const colors = [ - Color(0xFF0F766E), - Color(0xFF0B7BFF), - Color(0xFF7C3AED), - Color(0xFFFF6B00), - Color(0xFF0891B2), - Color(0xFFDB2777), - ]; - return colors[(index - 1).abs() % colors.length]; - } -} - -class CompactMultiSelectField extends StatelessWidget { - const CompactMultiSelectField({ - super.key, - required this.label, - required this.options, - required this.selected, - required this.onChanged, - }); - - final String label; - final List options; - final Set selected; - final ValueChanged> onChanged; - - @override - Widget build(BuildContext context) { - final cleanOptions = options - .map(decodeHtmlEntities) - .where((option) => option.isNotEmpty) - .toList(); - final cleanSelected = selected.map(decodeHtmlEntities).toSet(); - final summary = _summary(cleanSelected); - - return InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showPicker(context, cleanOptions, cleanSelected), - child: InputDecorator( - decoration: InputDecoration( - labelText: decodeHtmlEntities(label), - border: const OutlineInputBorder(), - suffixIcon: const Icon(Icons.expand_more), - ), - child: Text( - summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cleanSelected.isEmpty ? Colors.black54 : Colors.black87, - fontWeight: cleanSelected.isEmpty - ? FontWeight.w500 - : FontWeight.w700, - ), - ), - ), - ); - } - - String _summary(Set values) { - if (values.isEmpty) { - return 'Belum ada pilihan'; - } - if (values.length <= 2) { - return values.join(', '); - } - return '${values.length} pilihan dipilih'; - } - - Future _showPicker( - BuildContext context, - List cleanOptions, - Set cleanSelected, - ) async { - final draft = cleanSelected.toSet(); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) { - return StatefulBuilder( - builder: (context, setSheetState) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - decodeHtmlEntities(label), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height * 0.58, - ), - child: ListView.separated( - shrinkWrap: true, - itemCount: cleanOptions.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final option = cleanOptions[index]; - return CheckboxListTile( - value: draft.contains(option), - title: Text(option), - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - onChanged: (checked) { - setSheetState(() { - if (checked == true) { - draft.add(option); - } else { - draft.remove(option); - } - }); - }, - ); - }, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - TextButton( - onPressed: () { - draft.clear(); - onChanged(draft); - Navigator.of(context).pop(); - }, - child: const Text('Kosongkan'), - ), - const Spacer(), - FilledButton.icon( - onPressed: () { - onChanged(draft); - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.check), - label: const Text('Selesai'), - ), - ], - ), - ], - ), - ), - ); - }, - ); - }, - ); - } -} - -class ExpertiseHtmlEditor extends StatefulWidget { - const ExpertiseHtmlEditor({ - super.key, - required this.controller, - this.label = 'Expertise', - }); - - final TextEditingController controller; - final String label; - - @override - State createState() => _ExpertiseHtmlEditorState(); -} - -class _ExpertiseHtmlEditorState extends State { - bool _preview = false; - - @override - Widget build(BuildContext context) { - 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.stretch, - children: [ - Row( - children: [ - Text( - widget.label, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900), - ), - const Spacer(), - SegmentedButton( - segments: const [ - ButtonSegment(value: false, label: Text('Edit')), - ButtonSegment(value: true, label: Text('Preview')), - ], - selected: {_preview}, - onSelectionChanged: (value) => - setState(() => _preview = value.first), - showSelectedIcon: false, - style: const ButtonStyle( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - const SizedBox(height: 10), - _EditorToolbar( - onBold: () => _wrapSelection('', ''), - onItalic: () => _wrapSelection('', ''), - onUnderline: () => _wrapSelection('', ''), - onParagraph: () => _wrapSelection('

', '

'), - onBullet: _insertBulletList, - onBreak: () => _insertText('
'), - onClear: _clearTags, - ), - const SizedBox(height: 10), - if (_preview) - Container( - constraints: const BoxConstraints(minHeight: 210), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - border: Border.all(color: const Color(0xFFE2E8F0)), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _plainPreview(widget.controller.text), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - height: 1.45, - fontWeight: FontWeight.w600, - ), - ), - ) - else - TextField( - controller: widget.controller, - minLines: 10, - maxLines: 16, - keyboardType: TextInputType.multiline, - decoration: const InputDecoration( - hintText: 'Tulis expertise...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - ), - ], - ), - ), - ); - } - - void _wrapSelection(String open, String close) { - final selection = widget.controller.selection; - final text = widget.controller.text; - if (!selection.isValid || selection.isCollapsed) { - _insertText('$open$close', cursorOffset: open.length); - return; - } - - final selectedText = text.substring(selection.start, selection.end); - final replacement = '$open$selectedText$close'; - widget.controller.value = TextEditingValue( - text: text.replaceRange(selection.start, selection.end, replacement), - selection: TextSelection.collapsed( - offset: selection.start + replacement.length, - ), - ); - } - - void _insertText(String value, {int? cursorOffset}) { - final selection = widget.controller.selection; - final text = widget.controller.text; - final start = selection.isValid ? selection.start : text.length; - final end = selection.isValid ? selection.end : text.length; - widget.controller.value = TextEditingValue( - text: text.replaceRange(start, end, value), - selection: TextSelection.collapsed( - offset: start + (cursorOffset ?? value.length), - ), - ); - } - - void _insertBulletList() { - final selection = widget.controller.selection; - final text = widget.controller.text; - if (selection.isValid && !selection.isCollapsed) { - final selectedText = text.substring(selection.start, selection.end); - final items = selectedText - .split('\n') - .where((line) => line.trim().isNotEmpty) - .map((line) => '
  • ${line.trim()}
  • ') - .join(); - final replacement = '
      $items
    '; - widget.controller.value = TextEditingValue( - text: text.replaceRange(selection.start, selection.end, replacement), - selection: TextSelection.collapsed( - offset: selection.start + replacement.length, - ), - ); - return; - } - _insertText('
    ', cursorOffset: '
    • '.length); - } - - void _clearTags() { - final clean = _plainPreview(widget.controller.text); - widget.controller.value = TextEditingValue( - text: clean, - selection: TextSelection.collapsed(offset: clean.length), - ); - } - - String _plainPreview(String value) { - return decodeHtmlEntities( - value - .replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n') - .replaceAll(RegExp(r'', caseSensitive: false), '\n\n') - .replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ') - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'<[^>]*>'), ''), - ).trim(); - } -} - -class _EditorToolbar extends StatelessWidget { - const _EditorToolbar({ - required this.onBold, - required this.onItalic, - required this.onUnderline, - required this.onParagraph, - required this.onBullet, - required this.onBreak, - required this.onClear, - }); - - final VoidCallback onBold; - final VoidCallback onItalic; - final VoidCallback onUnderline; - final VoidCallback onParagraph; - final VoidCallback onBullet; - final VoidCallback onBreak; - final VoidCallback onClear; - - @override - Widget build(BuildContext context) { - return Wrap( - spacing: 6, - runSpacing: 6, - children: [ - _tool(Icons.format_bold, 'Bold', onBold), - _tool(Icons.format_italic, 'Italic', onItalic), - _tool(Icons.format_underlined, 'Underline', onUnderline), - _tool(Icons.notes, 'Paragraph', onParagraph), - _tool(Icons.format_list_bulleted, 'Bullet', onBullet), - _tool(Icons.keyboard_return, 'Line break', onBreak), - _tool(Icons.format_clear, 'Clear', onClear), - ], - ); - } - - Widget _tool(IconData icon, String tooltip, VoidCallback onPressed) { - return IconButton.outlined( - tooltip: tooltip, - onPressed: onPressed, - icon: Icon(icon), - visualDensity: VisualDensity.compact, - ); - } -} - -class AntibioticSensitivityPanel extends StatefulWidget { - const AntibioticSensitivityPanel({ - super.key, - required this.textControllers, - required this.selectValues, - required this.onChanged, - }); - - final Map textControllers; - final Map selectValues; - final VoidCallback onChanged; - - @override - State createState() => - _AntibioticSensitivityPanelState(); -} - -class _AntibioticSensitivityPanelState - extends State { - int _selectedMedia = 0; - - @override - Widget build(BuildContext context) { - final selected = _antibioticMedia[_selectedMedia]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionTitle(context, 'C. Tes Kepekaan Antibiotik'), - Text( - 'S: Sensitif, I: Intermediate, R: Resisten', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - _latestSummary(), - const SizedBox(height: 12), - _mediaTabs(), - const SizedBox(height: 12), - _actionToolbar(selected), - const SizedBox(height: 12), - _mediaTable(selected), - ], - ); - } - - Widget _sectionTitle(BuildContext context, String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - color: const Color(0xFF0F766E), - ), - ), - ); - } - - Widget _latestSummary() { - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Ringkasan Hasil Terakhir', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w800), - ), - const Spacer(), - const Icon(Icons.update, size: 15, color: Colors.black45), - ], - ), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _antibioticMedia.take(4).map((media) { - final latest = _rows(media).isNotEmpty - ? _rows(media).last - : {}; - final mediaText = - latest['media']?.toString().trim().isNotEmpty == true - ? latest['media'].toString() - : '-'; - final status = - widget.selectValues[media.statusField] ?? - latest['status']?.toString() ?? - '-'; - return Container( - width: 172, - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE2E8F0)), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - media.label, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 8), - Text( - mediaText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: media.color, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 8), - BadgeLabel(text: 'Status: $status', color: media.color), - ], - ), - ); - }).toList(), - ), - ), - ], - ), - ), - ); - } - - Widget _mediaTabs() { - return SizedBox( - height: 82, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _antibioticMedia.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final media = _antibioticMedia[index]; - final selected = index == _selectedMedia; - return InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => setState(() => _selectedMedia = index), - child: Container( - width: 158, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: selected - ? media.color.withValues(alpha: 0.10) - : Colors.white, - border: Border.all( - color: selected ? media.color : const Color(0xFFE2E8F0), - ), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - media.icon, - size: 16, - color: selected ? media.color : Colors.black54, - ), - const SizedBox(width: 5), - Flexible( - child: Text( - media.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: selected ? media.color : Colors.black87, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - BadgeLabel( - text: '${_rows(media).length} data', - color: selected ? media.color : const Color(0xFF64748B), - ), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _actionToolbar(_AntibioticMedia media) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(8), - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: () => _openEditor(media), - icon: const Icon(Icons.add), - label: const Text('Ada Pertumbuhan'), - ), - OutlinedButton.icon( - onPressed: () => _quickStatus(media, 'Tidak Ada Pertumbuhan'), - icon: const Icon(Icons.event_busy_outlined), - label: const Text('Tidak Ada Pertumbuhan'), - ), - OutlinedButton.icon( - onPressed: () => _quickStatus(media, 'Pertumbuhan Primer'), - icon: const Icon(Icons.calendar_month_outlined), - label: const Text('Pertumbuhan Primer'), - ), - ], - ), - ); - } - - Widget _mediaTable(_AntibioticMedia media) { - final rows = _rows(media); - if (rows.isEmpty) { - return EmptyPanel(text: 'Belum ada data ${media.label}.'); - } - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - headingRowHeight: 40, - dataRowMinHeight: 46, - dataRowMaxHeight: 58, - columns: [ - const DataColumn(label: Text('#')), - const DataColumn(label: Text('Tanggal')), - const DataColumn(label: Text('Petugas')), - for (final column in media.tableColumns) - DataColumn(label: Text(column.label)), - const DataColumn(label: Text('Edit')), - const DataColumn(label: Text('Delete')), - const DataColumn(label: Text('Next')), - ], - rows: [ - for (var i = 0; i < rows.length; i += 1) - DataRow( - cells: [ - DataCell(Text('${i + 1}')), - DataCell(Text(_cell(rows[i], 'tanggal'))), - DataCell(Text(_cell(rows[i], 'petugas'))), - for (final column in media.tableColumns) - DataCell(Text(_cell(rows[i], column.key))), - DataCell( - IconButton( - tooltip: 'Edit', - icon: const Icon(Icons.edit_outlined), - onPressed: () => _openEditor(media, index: i), - ), - ), - DataCell( - IconButton( - tooltip: 'Delete', - color: const Color(0xFFDC2626), - icon: const Icon(Icons.delete_outline), - onPressed: () => _deleteRow(media, i), - ), - ), - DataCell( - OutlinedButton( - onPressed: () => _nextRow(media, i), - child: const Text('Next'), - ), - ), - ], - ), - ], - ), - ), - ); - } - - String _cell(Map row, String key) { - final value = row[key]?.toString() ?? ''; - return value.isEmpty ? '-' : decodeHtmlEntities(value); - } - - List> _rows(_AntibioticMedia media) { - final raw = widget.textControllers[media.rowsField]?.text ?? ''; - if (raw.trim().isEmpty) { - return >[]; - } - try { - final decoded = jsonDecode(raw); - if (decoded is List) { - return decoded.map((item) => asMap(item)).toList(); - } - } catch (_) { - return >[]; - } - return >[]; - } - - void _saveRows(_AntibioticMedia media, List> rows) { - widget.textControllers[media.rowsField] ??= TextEditingController(); - widget.textControllers[media.rowsField]!.text = jsonEncode(rows); - widget.onChanged(); - } - - void _quickStatus(_AntibioticMedia media, String status) { - widget.selectValues[media.statusField] = status; - final rows = _rows(media); - rows.add({ - 'tanggal': _today(), - 'petugas': 'Petugas', - 'kuman': '', - 'media': status, - 'status': status, - }); - _saveRows(media, rows); - setState(() {}); - } - - Future _deleteRow(_AntibioticMedia media, int index) async { - final rows = _rows(media); - if (index < 0 || index >= rows.length) { - return; - } - rows.removeAt(index); - _saveRows(media, rows); - setState(() {}); - } - - Future _nextRow(_AntibioticMedia media, int index) async { - final rows = _rows(media); - if (index < 0 || index >= rows.length) { - return; - } - rows[index]['status'] = 'Sub Kultur'; - widget.selectValues[media.statusField] = 'Sub Kultur'; - _saveRows(media, rows); - setState(() {}); - } - - Future _openEditor(_AntibioticMedia media, {int? index}) async { - final rows = _rows(media); - final current = index == null ? {} : rows[index]; - final controllers = { - 'tanggal': TextEditingController( - text: _fieldValue(current, 'tanggal').isEmpty - ? _today() - : _fieldValue(current, 'tanggal'), - ), - 'petugas': TextEditingController( - text: _fieldValue(current, 'petugas').isEmpty - ? 'Petugas' - : _fieldValue(current, 'petugas'), - ), - for (final field in media.modalFields) - field.key: TextEditingController(text: _fieldValue(current, field.key)), - }; - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) { - return SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - MediaQuery.viewInsetsOf(context).bottom + 16, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '${index == null ? 'Tambah' : 'Edit'} ${media.label}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - Text( - media.modalId, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.black54), - ), - const SizedBox(height: 12), - _modalText(controllers['tanggal']!, 'Tanggal'), - _modalText(controllers['petugas']!, 'Petugas'), - for (final field in media.modalFields) - _modalField(controllers[field.key]!, field), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: () { - final row = { - 'tanggal': controllers['tanggal']!.text.trim(), - 'petugas': controllers['petugas']!.text.trim(), - for (final field in media.modalFields) - field.key: controllers[field.key]!.text.trim(), - }; - if (index == null) { - rows.add(row); - } else { - rows[index] = row; - } - widget.selectValues[media.statusField] = - (row['status'] ?? '').toString().isEmpty - ? 'Ada Pertumbuhan' - : row['status'].toString(); - _saveRows(media, rows); - setState(() {}); - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.save_outlined), - label: const Text('Simpan'), - ), - ], - ), - ), - ), - ); - }, - ); - - // Bottom sheet close/keyboard animations can still read these controllers - // for a frame after `showModalBottomSheet` completes. - } - - String _fieldValue(Map row, String key) { - final value = row[key]?.toString() ?? ''; - return decodeHtmlEntities(value); - } - - Widget _modalText(TextEditingController controller, String label) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: TextField( - controller: controller, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - ), - ); - } - - Widget _modalField(TextEditingController controller, _AntibioticField field) { - if (field.options.isNotEmpty) { - final current = field.options.contains(controller.text) - ? controller.text - : null; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: DropdownButtonFormField( - initialValue: current, - isExpanded: true, - decoration: InputDecoration( - labelText: field.label, - border: const OutlineInputBorder(), - ), - items: field.options - .map( - (option) => - DropdownMenuItem(value: option, child: Text(option)), - ) - .toList(), - onChanged: (value) => controller.text = value ?? '', - ), - ); - } - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: TextField( - controller: controller, - minLines: field.multiline ? 3 : 1, - maxLines: field.multiline ? 5 : 1, - decoration: InputDecoration( - labelText: field.label, - border: const OutlineInputBorder(), - ), - ), - ); - } - - String _today() { - final now = DateTime.now(); - return '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; - } -} - -class ToolDataPanel extends StatefulWidget { - const ToolDataPanel({ - super.key, - required this.textControllers, - required this.selectValues, - required this.onChanged, - }); - - final Map textControllers; - final Map selectValues; - final VoidCallback onChanged; - - @override - State createState() => _ToolDataPanelState(); -} - -class _ToolDataPanelState extends State { - int _selectedTab = 0; - - static const List<_ToolTabConfig> _tabs = [ - _ToolTabConfig( - label: 'Vitek', - tableId: 'tblvitek', - rowField: 'vitek_antibiotic_rows', - bacteriaField: 'bakteri', - sirField: 'bakterisir', - colonyField: 'id_bakterihitungkol', - colonyTextField: 'id_bakterihitungkolteks', - printField: 'id_baktericetak', - automatic: true, - ), - _ToolTabConfig( - label: 'Malditof', - tableId: 'tblkumanmanual1', - rowField: 'malditof_antibiotic_rows', - bacteriaField: 'id_bakteri01', - antibioticSetField: 'id_antibiotikmanual1', - sirField: 'id_bakterisir01', - colonyField: 'id_bakterihitungkol01', - colonyTextField: 'id_bakterihitungkolteks01', - printField: 'id_bakteri01cetak', - ), - _ToolTabConfig( - label: 'Manual', - tableId: 'tblkumanmanual2', - rowField: 'manual_antibiotic_rows', - bacteriaField: 'id_bakteri02', - antibioticSetField: 'id_antibiotikmanual2', - sirField: 'id_bakterisir02', - colonyField: 'id_bakterihitungkol02', - colonyTextField: 'id_bakterihitungkolteks02', - printField: 'id_bakteri02cetak', - ), - ]; - - @override - Widget build(BuildContext context) { - final tab = _tabs[_selectedTab]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Data Alat', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - color: const Color(0xFF0F766E), - ), - ), - const SizedBox(height: 10), - _tabBar(), - const SizedBox(height: 12), - _toolForm(tab), - const SizedBox(height: 12), - _toolButtons(tab), - const SizedBox(height: 12), - _antibioticTable(tab), - ], - ); - } - - Widget _tabBar() { - return SizedBox( - height: 54, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _tabs.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final tab = _tabs[index]; - final selected = index == _selectedTab; - return ChoiceChip( - selected: selected, - showCheckmark: false, - avatar: Icon( - index == 0 - ? Icons.memory_outlined - : index == 1 - ? Icons.biotech_outlined - : Icons.edit_note_outlined, - size: 18, - color: selected ? const Color(0xFF0F766E) : Colors.black54, - ), - label: Text(tab.label), - onSelected: (_) => setState(() => _selectedTab = index), - ); - }, - ), - ); - } - - Widget _toolForm(_ToolTabConfig tab) { - final rows = _rows(tab); - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - tab.label, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - ), - BadgeLabel( - text: '${rows.length} antibiotik', - color: const Color(0xFF0F766E), - ), - ], - ), - const SizedBox(height: 12), - if (tab.automatic) - _readonlyBacteria(tab) - else - _bacteriaAutocomplete(tab), - if (tab.antibioticSetField != null) - _selectText( - tab.antibioticSetField!, - 'Set Antibiotik', - _antibioticSetOptions, - onChanged: () => _autoGenerate(tab), - ), - _selectText( - tab.sirField, - 'Resistensi', - _sirOptions, - onChanged: () => _autoGenerate(tab), - ), - _selectText(tab.colonyField, 'Hitung Koloni', _colonyOptions), - _text(tab.colonyTextField, 'Hitung Koloni Lainnya'), - _selectText(tab.printField, 'Cetak', const ['YA', 'TIDAK']), - ], - ), - ), - ); - } - - Widget _readonlyBacteria(_ToolTabConfig tab) { - final controller = widget.textControllers[tab.bacteriaField] ??= - TextEditingController(); - final text = controller.text.trim(); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TextField( - controller: controller, - readOnly: true, - decoration: InputDecoration( - labelText: 'Kuman', - helperText: text.isEmpty - ? 'Readonly dari hasil alat Vitek' - : 'Readonly dari hasil alat Vitek', - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.lock_outline), - ), - ), - ); - } - - Widget _bacteriaAutocomplete(_ToolTabConfig tab) { - final controller = widget.textControllers[tab.bacteriaField] ??= - TextEditingController(); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Autocomplete( - initialValue: TextEditingValue(text: controller.text), - optionsBuilder: (value) { - final query = value.text.trim().toLowerCase(); - if (query.isEmpty) { - return _bacteriaOptions; - } - return _bacteriaOptions.where( - (option) => option.toLowerCase().contains(query), - ); - }, - onSelected: (value) { - controller.text = value; - _autoGenerate(tab); - }, - fieldViewBuilder: - (context, fieldController, focusNode, onFieldSubmitted) { - if (fieldController.text != controller.text) { - fieldController.text = controller.text; - } - return TextField( - controller: fieldController, - focusNode: focusNode, - decoration: const InputDecoration( - labelText: 'Kuman', - border: OutlineInputBorder(), - ), - onChanged: (value) { - controller.text = value; - widget.onChanged(); - }, - onSubmitted: (_) => _autoGenerate(tab), - ); - }, - ), - ); - } - - Widget _toolButtons(_ToolTabConfig tab) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(8), - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: () => _generateRows(tab, replace: true), - icon: const Icon(Icons.table_rows_outlined), - label: Text( - tab.automatic ? 'Muat Tabel Otomatis' : 'Generate Tabel', - ), - ), - OutlinedButton.icon( - onPressed: () => _setAllPrint(tab, printRow: true, printMic: true), - icon: const Icon(Icons.check_circle_outline), - label: const Text('Print Semua'), - ), - OutlinedButton.icon( - onPressed: () => - _setAllPrint(tab, printRow: false, printMic: false), - icon: const Icon(Icons.block_outlined), - label: const Text('Unprint Semua'), - ), - OutlinedButton.icon( - onPressed: () => _clearRows(tab), - icon: const Icon(Icons.delete_sweep_outlined), - label: const Text('Reset Tabel'), - ), - ], - ), - ); - } - - Widget _antibioticTable(_ToolTabConfig tab) { - final rows = _rows(tab); - if (rows.isEmpty) { - return EmptyPanel( - text: tab.automatic - ? 'Belum ada tabel antibiotik otomatis dari Vitek.' - : 'Pilih kuman dan resistensi untuk membuat tabel antibiotik.', - ); - } - - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - child: Text( - tab.tableId, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: Colors.black54), - ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - headingRowHeight: 40, - dataRowMinHeight: 60, - dataRowMaxHeight: 76, - columns: [ - const DataColumn(label: Text('#')), - const DataColumn(label: Text('Print')), - const DataColumn(label: Text('Antibiotik')), - const DataColumn(label: Text('Value')), - const DataColumn(label: Text('Interpretation')), - if (!tab.automatic) ...[ - const DataColumn(label: Text('S')), - const DataColumn(label: Text('I')), - const DataColumn(label: Text('R')), - ], - const DataColumn(label: Text('Print Value')), - const DataColumn(label: Text('Delete')), - ], - rows: [ - for (var i = 0; i < rows.length; i += 1) - DataRow( - cells: [ - DataCell(Text('${i + 1}')), - DataCell( - IconButton( - tooltip: rows[i]['printrow'] == true - ? 'Unprint row' - : 'Print row', - onPressed: () => _toggleBool(tab, i, 'printrow'), - icon: Icon( - rows[i]['printrow'] == true - ? Icons.check_circle - : Icons.block, - color: rows[i]['printrow'] == true - ? const Color(0xFF15803D) - : const Color(0xFFDC2626), - ), - ), - ), - DataCell( - SizedBox( - width: 150, - child: Text( - _cell(rows[i], 'antibiotic'), - overflow: TextOverflow.ellipsis, - ), - ), - ), - DataCell( - SizedBox( - width: 104, - child: TextFormField( - initialValue: _cell(rows[i], 'value'), - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - ), - onChanged: (value) => - _updateRow(tab, i, 'value', value), - ), - ), - ), - DataCell( - SizedBox( - width: 118, - child: DropdownButtonFormField( - initialValue: - _interpretationOptions.contains( - rows[i]['interpretation'], - ) - ? rows[i]['interpretation'].toString() - : '', - isExpanded: true, - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - ), - items: _interpretationOptions - .map( - (option) => DropdownMenuItem( - value: option, - child: Text( - option.isEmpty ? 'null' : option, - ), - ), - ) - .toList(), - onChanged: (value) => _updateRow( - tab, - i, - 'interpretation', - value ?? '', - ), - ), - ), - ), - if (!tab.automatic) ...[ - DataCell(Text(_cell(rows[i], 'batasatas'))), - DataCell(Text(_cell(rows[i], 'midrange'))), - DataCell(Text(_cell(rows[i], 'batasbawah'))), - ], - DataCell( - IconButton( - tooltip: rows[i]['printcol'] == true - ? 'Unprint value' - : 'Print value', - onPressed: () => _toggleBool(tab, i, 'printcol'), - icon: Icon( - rows[i]['printcol'] == true - ? Icons.check_circle - : Icons.block, - color: rows[i]['printcol'] == true - ? const Color(0xFF15803D) - : const Color(0xFFDC2626), - ), - ), - ), - DataCell( - IconButton( - tooltip: 'Delete MIC', - color: const Color(0xFFDC2626), - onPressed: () => _deleteRow(tab, i), - icon: const Icon(Icons.delete_outline), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _selectText( - String name, - String label, - List options, { - VoidCallback? onChanged, - }) { - final current = widget.selectValues[name] ?? ''; - final effectiveOptions = [ - if (current.isNotEmpty && !options.contains(current)) current, - ...options, - ]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: DropdownButtonFormField( - initialValue: 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(decodeHtmlEntities(option)), - ), - ) - .toList(), - onChanged: (value) { - widget.selectValues[name] = value ?? ''; - widget.onChanged(); - onChanged?.call(); - }, - ), - ); - } - - Widget _text(String name, String label) { - final controller = widget.textControllers[name] ??= TextEditingController(); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TextField( - controller: controller, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - onChanged: (_) => widget.onChanged(), - ), - ); - } - - List> _rows(_ToolTabConfig tab) { - final raw = widget.textControllers[tab.rowField]?.text ?? ''; - if (raw.trim().isEmpty) { - return >[]; - } - try { - final decoded = jsonDecode(raw); - if (decoded is List) { - return decoded.map((item) => asMap(item)).toList(); - } - } catch (_) { - return >[]; - } - return >[]; - } - - void _saveRows(_ToolTabConfig tab, List> rows) { - widget.textControllers[tab.rowField] ??= TextEditingController(); - widget.textControllers[tab.rowField]!.text = jsonEncode(rows); - widget.onChanged(); - } - - void _autoGenerate(_ToolTabConfig tab) { - if (tab.automatic) { - return; - } - final bacteria = - widget.textControllers[tab.bacteriaField]?.text.trim() ?? ''; - final sir = widget.selectValues[tab.sirField]?.trim() ?? ''; - if (bacteria.isEmpty || sir.isEmpty) { - return; - } - _generateRows(tab, replace: true); - } - - void _generateRows(_ToolTabConfig tab, {required bool replace}) { - final rows = replace ? >[] : _rows(tab); - final bacteria = - widget.textControllers[tab.bacteriaField]?.text.trim() ?? ''; - final resistance = widget.selectValues[tab.sirField]?.trim() ?? ''; - final setName = tab.antibioticSetField == null - ? 'otomatis' - : widget.selectValues[tab.antibioticSetField!]?.trim() ?? 'default'; - final antibiotics = _antibioticsFor(tab, bacteria, resistance, setName); - - for (var i = 0; i < antibiotics.length; i += 1) { - final antibiotic = antibiotics[i]; - rows.add({ - 'id': '${tab.rowField}_${DateTime.now().microsecondsSinceEpoch}_$i', - 'antibiotic': antibiotic, - 'value': '', - 'interpretation': '', - 'batasatas': 'S', - 'midrange': 'I', - 'batasbawah': 'R', - 'printrow': true, - 'printcol': true, - 'bacteria': bacteria, - 'resistance': resistance, - 'set': setName, - }); - } - _saveRows(tab, rows); - setState(() {}); - } - - List _antibioticsFor( - _ToolTabConfig tab, - String bacteria, - String resistance, - String setName, - ) { - final base = tab.automatic - ? _vitekAntibiotics - : bacteria.toLowerCase().contains('candida') || - bacteria.toLowerCase().contains('yeast') || - bacteria.toLowerCase().contains('jamur') - ? _fungalAntibiotics - : _manualAntibiotics; - if (resistance.contains('Carbapenem')) { - return [...base, 'ETP', 'DOR', 'IPM']; - } - if (resistance == 'MRSA') { - return ['FOX', 'OXA', 'VAN', 'LZD', 'CLI', 'ERY', ...base.take(5)]; - } - if (setName.toLowerCase() != 'default') { - return [setName, ...base.where((item) => item != setName)]; - } - return base; - } - - void _setAllPrint( - _ToolTabConfig tab, { - required bool printRow, - required bool printMic, - }) { - final rows = _rows(tab) - .map((row) => {...row, 'printrow': printRow, 'printcol': printMic}) - .toList(); - _saveRows(tab, rows); - setState(() {}); - } - - void _clearRows(_ToolTabConfig tab) { - _saveRows(tab, >[]); - setState(() {}); - } - - void _toggleBool(_ToolTabConfig tab, int index, String key) { - final rows = _rows(tab); - if (index < 0 || index >= rows.length) { - return; - } - rows[index][key] = rows[index][key] != true; - _saveRows(tab, rows); - setState(() {}); - } - - void _updateRow(_ToolTabConfig tab, int index, String key, String value) { - final rows = _rows(tab); - if (index < 0 || index >= rows.length) { - return; - } - rows[index][key] = value; - _saveRows(tab, rows); - } - - void _deleteRow(_ToolTabConfig tab, int index) { - final rows = _rows(tab); - if (index < 0 || index >= rows.length) { - return; - } - rows.removeAt(index); - _saveRows(tab, rows); - setState(() {}); - } - - String _cell(Map row, String key) { - final value = row[key]?.toString() ?? ''; - return value.isEmpty ? '-' : decodeHtmlEntities(value); - } -} - -class _ToolTabConfig { - const _ToolTabConfig({ - required this.label, - required this.tableId, - required this.rowField, - required this.bacteriaField, - required this.sirField, - required this.colonyField, - required this.colonyTextField, - required this.printField, - this.antibioticSetField, - this.automatic = false, - }); - - final String label; - final String tableId; - final String rowField; - final String bacteriaField; - final String sirField; - final String colonyField; - final String colonyTextField; - final String printField; - final String? antibioticSetField; - final bool automatic; -} - -class _AntibioticMedia { - const _AntibioticMedia({ - required this.label, - required this.modalId, - required this.statusField, - required this.rowsField, - required this.color, - required this.icon, - required this.modalFields, - required this.tableColumns, - }); - - final String label; - final String modalId; - final String statusField; - final String rowsField; - final Color color; - final IconData icon; - final List<_AntibioticField> modalFields; - final List<_AntibioticColumn> tableColumns; -} - -class _AntibioticField { - const _AntibioticField( - this.key, - this.label, { - this.options = const [], - this.multiline = false, - }); - - final String key; - final String label; - final List options; - final bool multiline; -} - -class _AntibioticColumn { - const _AntibioticColumn(this.key, this.label); - - final String key; - final String label; -} - -const List _kumanOptions = [ - 'Kuman 1', - 'Kuman 2', - 'Kuman 3', - 'Kuman 4', - 'Kuman 5', -]; - -const List _bacteriaOptions = [ - 'Kuman 1', - 'Kuman 2', - 'Kuman 3', - 'Kuman 4', - 'Kuman 5', - 'Escherichia coli', - 'Klebsiella pneumoniae', - 'Pseudomonas aeruginosa', - 'Acinetobacter baumannii', - 'Staphylococcus aureus', - 'Staphylococcus epidermidis', - 'Enterococcus faecalis', - 'Enterococcus faecium', - 'Streptococcus pneumoniae', - 'Candida albicans', - 'Candida tropicalis', - 'Candida glabrata', -]; - -const List _antibioticSetOptions = [ - 'default', - 'Gram Positive', - 'Gram Negative', - 'Urine', - 'Blood', - 'Respiratory', - 'Candida', -]; - -const List _interpretationOptions = [ - '', - 'S', - 'I', - 'R', - 'Invalid', - 'No Result', - 'Error', - 'SDD', -]; - -const List _vitekAntibiotics = [ - 'AMK', - 'AMP', - 'AMC', - 'CAZ', - 'CIP', - 'CTX', - 'CRO', - 'FEP', - 'GEN', - 'LEV', - 'MEM', - 'SXT', -]; - -const List _manualAntibiotics = [ - 'AMP', - 'AMC', - 'SAM', - 'TZP', - 'CAZ', - 'CTX', - 'CRO', - 'FEP', - 'CIP', - 'LEV', - 'GEN', - 'AMK', - 'MEM', - 'SXT', -]; - -const List _fungalAntibiotics = [ - 'Fluconazole', - 'Voriconazole', - 'Itraconazole', - 'Amphotericin B', - 'Caspofungin', - 'Micafungin', -]; - -const List _fungalKumanOptions = [ - 'Bakteri Kuman 1', - 'Yeast Kuman 1', - 'Mold Kuman 1', - 'Bakteri Kuman 2', - 'Yeast Kuman 2', - 'Mold Kuman 2', - 'Bakteri Kuman 3', - 'Yeast Kuman 3', - 'Mold Kuman 3', - 'Bakteri Kuman 4', - 'Yeast Kuman 4', - 'Mold Kuman 4', - 'Bakteri Kuman 5', - 'Yeast Kuman 5', - 'Mold Kuman 5', -]; - -const List _hemolisaOptions = ['Alpha', 'Beta', 'Gamma']; -const List _posNegOptions = ['POS', 'NEG']; -const List _yesNoOptions = ['YA', 'TIDAK']; - -const List _mediaStatusOptions = [ - 'Inkubasi Lanjutan', - 'Sub Kultur', - 'Proses identifikasi dan uji kepekaan vitek', - 'Proses identifikasi dan uji kepekaan manual', - 'Proses identifikasi malditof', - 'Proses uji kepekaan vitek', - 'Prosesuji kepekaan manual', - 'ID/AST pending result', - 'Tidak Lanjut Identifikasi', - 'Menunggu kultur yg lain', -]; - -const List _fungalStatusOptions = [ - 'Tidak Lanjut Identifikasi', - 'PATOGEN (Lanjut Identifikasi Vitek)', - 'PATOGEN (Lanjut Identifikasi Manual)', - 'Subkultur', -]; - -const List _colonyCountOptions = [ - '>= 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', -]; - -const List<_AntibioticColumn> _standardColumns = [ - _AntibioticColumn('kuman', 'Kuman'), - _AntibioticColumn('media', 'Media'), - _AntibioticColumn('hemolisa', 'Hemolisa'), - _AntibioticColumn('katalase', 'Katalase'), - _AntibioticColumn('koagulase', 'Koagulase'), - _AntibioticColumn('oksidase', 'Oksidase'), - _AntibioticColumn('lainnya', 'Lainnya'), - _AntibioticColumn('status', 'Status'), -]; - -const List<_AntibioticColumn> _bapColumns = [ - _AntibioticColumn('kuman', 'Kuman'), - _AntibioticColumn('media', 'Media'), - _AntibioticColumn('hemolisa', 'Hemolisa'), - _AntibioticColumn('katalase', 'Katalase'), - _AntibioticColumn('koagulase', 'Koagulase'), - _AntibioticColumn('lainnya', 'Lainnya'), - _AntibioticColumn('hitungkoloni', 'Hitung Koloni'), - _AntibioticColumn('status', 'Status'), -]; - -const List<_AntibioticColumn> _capColumns = [ - _AntibioticColumn('kuman', 'Kuman'), - _AntibioticColumn('media', 'Media'), - _AntibioticColumn('katalase', 'Katalase'), - _AntibioticColumn('koagulase', 'Koagulase'), - _AntibioticColumn('lainnya', 'Lainnya'), - _AntibioticColumn('status', 'Status'), -]; - -const List<_AntibioticColumn> _mcConkeyColumns = [ - _AntibioticColumn('kuman', 'Kuman'), - _AntibioticColumn('media', 'Media'), - _AntibioticColumn('oksidase', 'Oksidase'), - _AntibioticColumn('lainnya', 'Lainnya'), - _AntibioticColumn('status', 'Status'), -]; - -const List<_AntibioticColumn> _fungalColumns = [ - _AntibioticColumn('kuman', 'Kuman'), - _AntibioticColumn('media', 'Media'), - _AntibioticColumn('hemolisa', 'Tumbuh'), - _AntibioticColumn('status', 'Status'), -]; - -const List<_AntibioticMedia> _antibioticMedia = [ - _AntibioticMedia( - label: 'Media BAP', - modalId: 'modalgridmediabap', - statusField: 'media_bap_status', - rowsField: 'media_bap_rows', - color: Color(0xFF15803D), - icon: Icons.monitor_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _kumanOptions), - _AntibioticField('media', 'Media BAP'), - _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), - _AntibioticField('katalase', 'Katalase', options: _posNegOptions), - _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), - _AntibioticField('lainnya', 'Uji Lainnya'), - _AntibioticField( - 'hitungkoloni', - 'Hitung Koloni', - options: _colonyCountOptions, - ), - _AntibioticField('hitungkoloniteks', 'Hitung Koloni Lainnya'), - _AntibioticField('status', 'Status', options: _mediaStatusOptions), - _AntibioticField('keterangan', 'Keterangan', multiline: true), - ], - tableColumns: _bapColumns, - ), - _AntibioticMedia( - label: 'Media CAP', - modalId: 'modalgridmediacap', - statusField: 'media_cap_status', - rowsField: 'media_cap_rows', - color: Color(0xFF2563EB), - icon: Icons.monitor_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _kumanOptions), - _AntibioticField('media', 'Media CAP'), - _AntibioticField('katalase', 'Katalase', options: _posNegOptions), - _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), - _AntibioticField('lainnya', 'Uji Lainnya'), - _AntibioticField('status', 'Status', options: _mediaStatusOptions), - ], - tableColumns: _capColumns, - ), - _AntibioticMedia( - label: 'Media Mc Conkey', - modalId: 'modalgridmediamcconkey', - statusField: 'media_mcconkey_status', - rowsField: 'media_mcconkey_rows', - color: Color(0xFF7C3AED), - icon: Icons.monitor_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _kumanOptions), - _AntibioticField('media', 'Media Mc Conkey'), - _AntibioticField('oksidase', 'Oksidase', options: _posNegOptions), - _AntibioticField('lainnya', 'Uji Lainnya'), - _AntibioticField('status', 'Status', options: _mediaStatusOptions), - ], - tableColumns: _mcConkeyColumns, - ), - _AntibioticMedia( - label: 'Kultur Jamur R1', - modalId: 'modalgridmediasdar1', - statusField: 'media_sdar1_status', - rowsField: 'media_sdar1_rows', - color: Color(0xFFD97706), - icon: Icons.eco_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), - _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), - _AntibioticField( - 'hemolisa', - 'Tumbuh di Area Inokulasi?', - options: _yesNoOptions, - ), - _AntibioticField('status', 'Status', options: _fungalStatusOptions), - ], - tableColumns: _fungalColumns, - ), - _AntibioticMedia( - label: 'Kultur Jamur R2', - modalId: 'modalgridmediasdar2', - statusField: 'media_sdar2_status', - rowsField: 'media_sdar2_rows', - color: Color(0xFFD97706), - icon: Icons.eco_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), - _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), - _AntibioticField( - 'hemolisa', - 'Tumbuh di Area Inokulasi?', - options: _yesNoOptions, - ), - _AntibioticField('status', 'Status', options: _fungalStatusOptions), - ], - tableColumns: _fungalColumns, - ), - _AntibioticMedia( - label: 'Kultur Jamur I1', - modalId: 'modalgridmediasdai1', - statusField: 'media_sdai1_status', - rowsField: 'media_sdai1_rows', - color: Color(0xFFD97706), - icon: Icons.eco_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), - _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), - _AntibioticField( - 'hemolisa', - 'Tumbuh di Area Inokulasi?', - options: _yesNoOptions, - ), - _AntibioticField('status', 'Status', options: _fungalStatusOptions), - ], - tableColumns: _fungalColumns, - ), - _AntibioticMedia( - label: 'Kultur Jamur I2', - modalId: 'modalgridmediasdai2', - statusField: 'media_sdai2_status', - rowsField: 'media_sdai2_rows', - color: Color(0xFFD97706), - icon: Icons.eco_outlined, - modalFields: [ - _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), - _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), - _AntibioticField( - 'hemolisa', - 'Tumbuh di Area Inokulasi?', - options: _yesNoOptions, - ), - _AntibioticField('status', 'Status', options: _fungalStatusOptions), - ], - tableColumns: _fungalColumns, - ), - _AntibioticMedia( - label: 'Media Selektif lainnya', - modalId: 'modalgridmediaselektif', - statusField: 'media_sellainnya_status', - rowsField: 'media_sellainnya_rows', - color: Color(0xFF0F766E), - icon: Icons.science_outlined, - modalFields: [ - _AntibioticField('media', 'Media Selektif lainnya'), - _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), - _AntibioticField('katalase', 'Katalase', options: _posNegOptions), - _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), - _AntibioticField('oksidase', 'Oksidase'), - _AntibioticField('lainnya', 'Uji Lainnya'), - _AntibioticField('status', 'Status', options: _mediaStatusOptions), - ], - tableColumns: _standardColumns, - ), - _AntibioticMedia( - label: 'Pemeriksaan Tambahan Lainnya', - modalId: 'modalgridmediatamlainnya', - statusField: 'media_tamlainnya_status', - rowsField: 'media_tamlainnya_rows', - color: Color(0xFF475569), - icon: Icons.assignment_outlined, - modalFields: [ - _AntibioticField('media', 'Pemeriksaan Tambahan'), - _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), - _AntibioticField('katalase', 'Katalase', options: _posNegOptions), - _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), - _AntibioticField('oksidase', 'Oksidase'), - _AntibioticField('lainnya', 'Uji Lainnya'), - _AntibioticField('status', 'Status', options: _mediaStatusOptions), - ], - tableColumns: _standardColumns, - ), -]; - -const List kAntibioticMediaRowFields = [ - 'media_bap_rows', - 'media_cap_rows', - 'media_mcconkey_rows', - 'media_sdar1_rows', - 'media_sdar2_rows', - 'media_sdai1_rows', - 'media_sdai2_rows', - 'media_sellainnya_rows', - 'media_tamlainnya_rows', -]; - -const List kToolAntibioticRowFields = [ - 'vitek_antibiotic_rows', - 'malditof_antibiotic_rows', - 'manual_antibiotic_rows', -]; - -class ExpertiseForm extends StatelessWidget { - const ExpertiseForm({ - super.key, - required this.dlp, - required this.fields, - required this.textControllers, - required this.selectValues, - required this.multiValues, - required this.onChanged, - }); - - final String dlp; - final List fields; - final Map textControllers; - final Map selectValues; - final Map> multiValues; - final VoidCallback onChanged; - - @override - Widget build(BuildContext context) { - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _templateTitle(dlp), - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 12), - ...fields.map((rawField) => _buildField(context, asMap(rawField))), - ], - ), - ), - ); - } - - Widget _buildField(BuildContext context, Map field) { - final name = field['name']?.toString() ?? ''; - final label = field['label']?.toString() ?? name; - final type = field['type']?.toString() ?? 'text'; - final options = asList( - field['options'], - ).map((item) => item.toString()).toList(); - - if (type == 'select') { - final currentValue = options.contains(selectValues[name]) - ? selectValues[name] - : null; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: DropdownButtonFormField( - initialValue: currentValue, - isExpanded: true, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - items: options - .map( - (option) => - DropdownMenuItem(value: option, child: Text(option)), - ) - .toList(), - onChanged: (value) { - selectValues[name] = value ?? ''; - onChanged(); - }, - ), - ); - } - - if (type == 'multiselect') { - final selected = multiValues[name] ?? {}; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: CompactMultiSelectField( - label: label, - options: options, - selected: selected, - onChanged: (value) { - multiValues[name] = value; - onChanged(); - }, - ), - ); - } - - final controller = textControllers[name] ??= TextEditingController(); - if (name == 'keterangan') { - return ExpertiseHtmlEditor(controller: controller, label: label); - } - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TextField( - controller: controller, - minLines: type == 'textarea' ? 4 : 1, - maxLines: type == 'textarea' ? 8 : 1, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - ), - ); - } -} - -class ExpertiseActions extends StatelessWidget { - const ExpertiseActions({ - super.key, - required this.isSupervisor, - required this.saving, - required this.onSave, - }); - - final bool isSupervisor; - final bool saving; - final ValueChanged onSave; - - @override - Widget build(BuildContext context) { - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton.icon( - onPressed: saving ? null : () => onSave('Draft'), - icon: saving - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_outlined), - label: const Text('Save as Draft'), - ), - if (isSupervisor) ...[ - OutlinedButton.icon( - onPressed: saving ? null : () => onSave('preliminary'), - icon: const Icon(Icons.done_all_outlined), - label: const Text('Save Preliminary result'), - ), - OutlinedButton.icon( - onPressed: saving ? null : () => onSave('verifikasi'), - icon: const Icon(Icons.verified_outlined), - label: const Text('Save Final Result'), - ), - ] else ...[ - OutlinedButton.icon( - onPressed: saving - ? null - : () => onSave('Permohonan Verifikasi'), - icon: const Icon(Icons.send_outlined), - label: const Text('Save and Send To SPV'), - ), - OutlinedButton.icon( - onPressed: saving - ? null - : () => onSave('Permohonan Verifikasi Preliminary'), - icon: const Icon(Icons.outgoing_mail), - label: const Text('Kirim SPV Preliminary'), - ), - ], - ], - ), - ), - ); - } -} diff --git a/mylis/lib/screens/expertise/expertise_screen.dart b/mylis/lib/screens/expertise/expertise_screen.dart deleted file mode 100644 index a37719b4..00000000 --- a/mylis/lib/screens/expertise/expertise_screen.dart +++ /dev/null @@ -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 createState() => _ExpertiseScreenState(); -} - -class _ExpertiseScreenState extends State { - late final ApiClient _api; - late Future> _future; - final Map _textControllers = {}; - final Map _selectValues = {}; - final Map> _multiValues = {}; - final Map _staffValues = {}; - final Map _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> _load() { - return _api.get('api/mobile/examinations/${widget.id}/expertise'); - } - - void _reload() { - setState(() { - _future = _load(); - }); - } - - void _prepareFields(Map 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() - : {}; - } 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 _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 _save(String action) async { - if (action == 'statuspewarnaanlsg') { - _wizardSteps[_currentDlp] = 1; - } - setState(() => _saving = true); - final fields = {}; - 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 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>( - 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 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, - ), - ), - ], - ), - ); - } -} diff --git a/mylis/lib/screens/expertise/kultur_expertise_wizard.dart b/mylis/lib/screens/expertise/kultur_expertise_wizard.dart deleted file mode 100644 index b323d5b7..00000000 --- a/mylis/lib/screens/expertise/kultur_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map optionSets; - final Map textControllers; - final Map selectValues; - final Map> multiValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => _KulturExpertiseWizardState(); -} - -class _KulturExpertiseWizardState extends State { - 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 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 {}).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 _options( - String key, [ - List 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 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( - 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 options) { - final selected = widget.multiValues[name] ?? {}; - 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 _scoreOptions = ['0', '1', '2', '3', '4']; - -const List _antibioticSirOptions = ['S', 'I', 'R']; - -const List _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 _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 _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', -]; diff --git a/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart b/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart deleted file mode 100644 index 5e9303d9..00000000 --- a/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map textControllers; - final Map selectValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => - _LeptospiraExpertiseWizardState(); -} - -class _LeptospiraExpertiseWizardState extends State { - 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 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 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( - 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(), - ), - ), - ); - } -} diff --git a/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart b/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart deleted file mode 100644 index 4d345245..00000000 --- a/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map optionSets; - final Map textControllers; - final Map selectValues; - final Map> multiValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => - _PewarnaanLangsungExpertiseWizardState(); -} - -class _PewarnaanLangsungExpertiseWizardState - extends State { - 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 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 {}).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 _options( - String key, [ - List 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 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( - 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 options) { - final selected = widget.multiValues[name] ?? {}; - 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(), - ), - ), - ); - } -} diff --git a/mylis/lib/screens/expertise/tbc_expertise_wizard.dart b/mylis/lib/screens/expertise/tbc_expertise_wizard.dart deleted file mode 100644 index d1542228..00000000 --- a/mylis/lib/screens/expertise/tbc_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map textControllers; - final Map selectValues; - final Map> multiValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => _TbcExpertiseWizardState(); -} - -class _TbcExpertiseWizardState extends State { - 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 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 {}).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 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( - 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 options) { - final selected = widget.multiValues[name] ?? {}; - 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 _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 _tcmRifTypes = { - 'TCM MTB Rif (Xpert)', - 'TCM MTB Rif (Truenat)', - 'TCM MTB Rif INH (BDMAX)', - 'LPA lini 1', - 'LPA lini 2', -}; - -const List _tbMicroscopeResults = ['+++', '++', '+', '1-9', 'Neg']; - -const List _tcmMtbRifResults = [ - 'Neg', - 'Rif Sen', - 'Rif Res', - 'Rif Indet', - 'Invalid', - 'Error', - 'No result', -]; - -const List _xdrValidityResults = [ - 'Valid', - 'Invalid', - 'No Result', - 'Error', -]; - -const List _xdrSirResults = [ - 'S', - 'I', - 'R', - 'Invalid', - 'Error', - 'No Result', -]; diff --git a/mylis/lib/screens/expertise/template_picker.dart b/mylis/lib/screens/expertise/template_picker.dart deleted file mode 100644 index 7ed8c925..00000000 --- a/mylis/lib/screens/expertise/template_picker.dart +++ /dev/null @@ -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 templates; - final bool saving; - final ValueChanged 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'), - ), - ), - ), - ), - ], - ); - } -} diff --git a/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart b/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart deleted file mode 100644 index 38846046..00000000 --- a/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart +++ /dev/null @@ -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 item; - final Map user; - final Map staffOptions; - final Map optionSets; - final Map textControllers; - final Map selectValues; - final Map staffValues; - final bool criticalValue; - final bool isSupervisor; - final bool saving; - final ValueChanged onCriticalChanged; - final VoidCallback onChanged; - final ValueChanged onSave; - final int initialStep; - final ValueChanged onStepChanged; - - @override - State createState() => - _ViralLoadExpertiseWizardState(); -} - -class _ViralLoadExpertiseWizardState extends State { - 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 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 _options( - String key, [ - List 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 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( - 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 _viralLoadResultOptions = [ - 'NOT DETECTED', - '<40', - '<1.60', - '40', - '41', - '44', - '45', - '145', - '<10', - '20', - '322', - '511', - '144', - '211', - 'lainnya', -]; - -const List _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', -]; diff --git a/mylis/lib/screens/login/login_screen.dart b/mylis/lib/screens/login/login_screen.dart deleted file mode 100644 index 51ea5d11..00000000 --- a/mylis/lib/screens/login/login_screen.dart +++ /dev/null @@ -1,304 +0,0 @@ -part of '../../app/app.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _formKey = GlobalKey(); - 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 _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 _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, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/mylis/lib/services/api_client.dart b/mylis/lib/services/api_client.dart deleted file mode 100644 index 47d0d9b6..00000000 --- a/mylis/lib/services/api_client.dart +++ /dev/null @@ -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? query]) { - final normalizedBase = normalizeBaseUrl(baseUrl); - final base = Uri.parse(normalizedBase); - return base.replace( - path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path', - queryParameters: query, - ); - } - - Map get _headers => { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }; - - Future> post( - String path, - Map body, - ) async { - return _sendWithRenew( - path, - (client, uri) => client - .post(uri, headers: _headers, body: jsonEncode(body)) - .timeout(const Duration(seconds: 15)), - ); - } - - Future> get( - String path, [ - Map? query, - ]) async { - return _sendWithRenew( - path, - (client, uri) => client - .get(uri, headers: _headers) - .timeout(const Duration(seconds: 15)), - query, - ); - } - - Future> _sendWithRenew( - String path, - Future Function(http.Client client, Uri uri) send, [ - Map? 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> _sendOnce( - Uri uri, - Future 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 _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 _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 - ? {} - : 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 - ? {} - : 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 - ? 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; -} diff --git a/mylis/lib/services/session_store.dart b/mylis/lib/services/session_store.dart deleted file mode 100644 index 77aff43a..00000000 --- a/mylis/lib/services/session_store.dart +++ /dev/null @@ -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 token() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_tokenKey); - } - - static Future baseUrl() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl; - } - - static Future 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 saveToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_tokenKey, token); - } - - static Future 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 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 saveBaseUrl(String baseUrl) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl)); - } - - static Future 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'/+$'), ''); -} diff --git a/mylis/lib/widgets/common_widgets.dart b/mylis/lib/widgets/common_widgets.dart deleted file mode 100644 index 4ca73243..00000000 --- a/mylis/lib/widgets/common_widgets.dart +++ /dev/null @@ -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 asMap(Object? value) { - if (value is Map) { - return value; - } - if (value is Map) { - return value.map((key, item) => MapEntry(key.toString(), item)); - } - return {}; -} - -List asList(Object? value) => value is List ? value : []; - -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'', 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; -} diff --git a/mylis/linux/.gitignore b/mylis/linux/.gitignore deleted file mode 100644 index d3896c98..00000000 --- a/mylis/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/mylis/linux/CMakeLists.txt b/mylis/linux/CMakeLists.txt deleted file mode 100644 index addb4ab8..00000000 --- a/mylis/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "mylis") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.duidev.mylis") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/mylis/linux/flutter/CMakeLists.txt b/mylis/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/mylis/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/mylis/linux/flutter/generated_plugin_registrant.cc b/mylis/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index f6f23bfe..00000000 --- a/mylis/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/mylis/linux/flutter/generated_plugin_registrant.h b/mylis/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/mylis/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mylis/linux/flutter/generated_plugins.cmake b/mylis/linux/flutter/generated_plugins.cmake deleted file mode 100644 index f16b4c34..00000000 --- a/mylis/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/mylis/linux/runner/CMakeLists.txt b/mylis/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc7..00000000 --- a/mylis/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/mylis/linux/runner/main.cc b/mylis/linux/runner/main.cc deleted file mode 100644 index e7c5c543..00000000 --- a/mylis/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/mylis/linux/runner/my_application.cc b/mylis/linux/runner/my_application.cc deleted file mode 100644 index 3e139149..00000000 --- a/mylis/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "mylis"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "mylis"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/mylis/linux/runner/my_application.h b/mylis/linux/runner/my_application.h deleted file mode 100644 index db16367a..00000000 --- a/mylis/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/mylis/macos/.gitignore b/mylis/macos/.gitignore deleted file mode 100644 index 746adbb6..00000000 --- a/mylis/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/mylis/macos/Flutter/Flutter-Debug.xcconfig b/mylis/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/mylis/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mylis/macos/Flutter/Flutter-Release.xcconfig b/mylis/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b6..00000000 --- a/mylis/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mylis/macos/Flutter/GeneratedPluginRegistrant.swift b/mylis/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index eb7589b7..00000000 --- a/mylis/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import mobile_scanner -import shared_preferences_foundation -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/mylis/macos/Runner.xcodeproj/project.pbxproj b/mylis/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 385e7434..00000000 --- a/mylis/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,729 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* mylis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mylis.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* mylis.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* mylis.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/mylis/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mylis/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/mylis/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mylis/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mylis/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 2cc29545..00000000 --- a/mylis/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/macos/Runner.xcworkspace/contents.xcworkspacedata b/mylis/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/mylis/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mylis/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mylis/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/mylis/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mylis/macos/Runner/AppDelegate.swift b/mylis/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c17614..00000000 --- a/mylis/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f1..00000000 --- a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 2cee7135..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 7b265efa..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 42a473ee..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index 4af208e1..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index dd0dc0fd..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 51f08497..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index edf91488..00000000 Binary files a/mylis/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/mylis/macos/Runner/Base.lproj/MainMenu.xib b/mylis/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4..00000000 --- a/mylis/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mylis/macos/Runner/Configs/AppInfo.xcconfig b/mylis/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 640795ed..00000000 --- a/mylis/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = MyLIS - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 Rumah Sakit Umum Daerah Dr. Saiful Anwar. All rights reserved. diff --git a/mylis/macos/Runner/Configs/Debug.xcconfig b/mylis/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/mylis/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/mylis/macos/Runner/Configs/Release.xcconfig b/mylis/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/mylis/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/mylis/macos/Runner/Configs/Warnings.xcconfig b/mylis/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/mylis/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/mylis/macos/Runner/DebugProfile.entitlements b/mylis/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 88410221..00000000 --- a/mylis/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,16 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.security.device.camera - - - diff --git a/mylis/macos/Runner/Info.plist b/mylis/macos/Runner/Info.plist deleted file mode 100644 index 2455074c..00000000 --- a/mylis/macos/Runner/Info.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSCameraUsageDescription - MyLIS memakai kamera untuk scan barcode sampel. - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/mylis/macos/Runner/MainFlutterWindow.swift b/mylis/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb2..00000000 --- a/mylis/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/mylis/macos/Runner/Release.entitlements b/mylis/macos/Runner/Release.entitlements deleted file mode 100644 index a32e4b14..00000000 --- a/mylis/macos/Runner/Release.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.device.camera - - - diff --git a/mylis/macos/RunnerTests/RunnerTests.swift b/mylis/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1f..00000000 --- a/mylis/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -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. - } - -} diff --git a/mylis/pubspec.lock b/mylis/pubspec.lock deleted file mode 100644 index ad0735ff..00000000 --- a/mylis/pubspec.lock +++ /dev/null @@ -1,442 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce - url: "https://pub.dev" - source: hosted - version: "7.4.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.dev" - source: hosted - version: "2.4.27" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 - url: "https://pub.dev" - source: hosted - version: "6.3.32" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" -sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" diff --git a/mylis/pubspec.yaml b/mylis/pubspec.yaml deleted file mode 100644 index 0a461ad4..00000000 --- a/mylis/pubspec.yaml +++ /dev/null @@ -1,97 +0,0 @@ -name: mylis -description: "Mikrobiology Laboratory Information System (MyLIS)" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.2+2 - -environment: - sdk: ^3.12.2 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - http: ^1.2.2 - shared_preferences: ^2.3.2 - url_launcher: ^6.3.1 - mobile_scanner: ^7.4.0 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - assets: - - assets/branding/logo.png - - assets/branding/logo_rssa.png - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/mylis/test/widget_test.dart b/mylis/test/widget_test.dart deleted file mode 100644 index 678d022e..00000000 --- a/mylis/test/widget_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:mylis/main.dart'; - -void main() { - testWidgets('shows login screen', (WidgetTester tester) async { - SharedPreferences.setMockInitialValues({}); - - await tester.pumpWidget(const MyLisApp()); - await tester.pump(); - - expect( - find.text('Mikrobiology Laboratory Information System'), - findsOneWidget, - ); - expect(find.text('Login'), findsOneWidget); - expect(find.text('Username'), findsOneWidget); - }); -} diff --git a/mylis/web/favicon.png b/mylis/web/favicon.png deleted file mode 100644 index 42a473ee..00000000 Binary files a/mylis/web/favicon.png and /dev/null differ diff --git a/mylis/web/icons/Icon-192.png b/mylis/web/icons/Icon-192.png deleted file mode 100644 index e98d7082..00000000 Binary files a/mylis/web/icons/Icon-192.png and /dev/null differ diff --git a/mylis/web/icons/Icon-512.png b/mylis/web/icons/Icon-512.png deleted file mode 100644 index 51f08497..00000000 Binary files a/mylis/web/icons/Icon-512.png and /dev/null differ diff --git a/mylis/web/icons/Icon-maskable-192.png b/mylis/web/icons/Icon-maskable-192.png deleted file mode 100644 index e98d7082..00000000 Binary files a/mylis/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/mylis/web/icons/Icon-maskable-512.png b/mylis/web/icons/Icon-maskable-512.png deleted file mode 100644 index 51f08497..00000000 Binary files a/mylis/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/mylis/web/index.html b/mylis/web/index.html deleted file mode 100644 index 466439e3..00000000 --- a/mylis/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Mikrobiology Laboratory Information System (MyLIS) - - - - - - - diff --git a/mylis/web/manifest.json b/mylis/web/manifest.json deleted file mode 100644 index 1510e9db..00000000 --- a/mylis/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Mikrobiology Laboratory Information System (MyLIS)", - "short_name": "MyLIS", - "start_url": ".", - "display": "standalone", - "background_color": "#F7FAF9", - "theme_color": "#0F766E", - "description": "Mikrobiology Laboratory Information System (MyLIS) milik Rumah Sakit Umum Daerah Dr. Saiful Anwar.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/mylis/windows/.gitignore b/mylis/windows/.gitignore deleted file mode 100644 index d492d0d9..00000000 --- a/mylis/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/mylis/windows/CMakeLists.txt b/mylis/windows/CMakeLists.txt deleted file mode 100644 index 5aadd243..00000000 --- a/mylis/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(mylis LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "mylis") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/mylis/windows/flutter/CMakeLists.txt b/mylis/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/mylis/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/mylis/windows/flutter/generated_plugin_registrant.cc b/mylis/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 4f788487..00000000 --- a/mylis/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); -} diff --git a/mylis/windows/flutter/generated_plugin_registrant.h b/mylis/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/mylis/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mylis/windows/flutter/generated_plugins.cmake b/mylis/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 88b22e5c..00000000 --- a/mylis/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - url_launcher_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/mylis/windows/runner/CMakeLists.txt b/mylis/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c0..00000000 --- a/mylis/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/mylis/windows/runner/Runner.rc b/mylis/windows/runner/Runner.rc deleted file mode 100644 index f375a65e..00000000 --- a/mylis/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "mylis" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "mylis" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "mylis.exe" "\0" - VALUE "ProductName", "mylis" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/mylis/windows/runner/flutter_window.cpp b/mylis/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee303..00000000 --- a/mylis/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/mylis/windows/runner/flutter_window.h b/mylis/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f..00000000 --- a/mylis/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/mylis/windows/runner/main.cpp b/mylis/windows/runner/main.cpp deleted file mode 100644 index f7850b57..00000000 --- a/mylis/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"mylis", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/mylis/windows/runner/resource.h b/mylis/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/mylis/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/mylis/windows/runner/resources/app_icon.ico b/mylis/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20ca..00000000 Binary files a/mylis/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/mylis/windows/runner/runner.exe.manifest b/mylis/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/mylis/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/mylis/windows/runner/utils.cpp b/mylis/windows/runner/utils.cpp deleted file mode 100644 index 3cb71466..00000000 --- a/mylis/windows/runner/utils.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - // First, find the length of the string with a safe upper bound (CWE-126). - // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. - int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); - // Now use that bounded length to determine the required buffer size. - // When an explicit length is passed, WideCharToMultiByte does not include - // the null terminator in its returned size. - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/mylis/windows/runner/utils.h b/mylis/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/mylis/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/mylis/windows/runner/win32_window.cpp b/mylis/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/mylis/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/mylis/windows/runner/win32_window.h b/mylis/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/mylis/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_