Files
lis/mylis/lib/main.dart
T
2026-07-21 05:45:47 +07:00

1275 lines
38 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
const String kDefaultBaseUrl = String.fromEnvironment(
'LIS_BASE_URL',
defaultValue: 'https://lis.swandhana.test/',
);
void main() {
runApp(const MyLisApp());
}
class MyLisApp extends StatelessWidget {
const MyLisApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MyLIS Mobile',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF7FAF9),
useMaterial3: true,
),
home: const SessionGate(),
);
}
}
class SessionGate extends StatefulWidget {
const SessionGate({super.key});
@override
State<SessionGate> createState() => _SessionGateState();
}
class _SessionGateState extends State<SessionGate> {
late final Future<SessionData> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = SessionStore.session();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<SessionData>(
future: _sessionFuture,
builder: (context, snapshot) {
if (!snapshot.hasData &&
snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
final session = snapshot.data ?? const SessionData();
return session.token == null
? const LoginScreen()
: DashboardScreen(token: session.token!, baseUrl: session.baseUrl);
},
);
}
}
class ApiClient {
ApiClient({required this.baseUrl, this.token});
final String baseUrl;
final String? token;
Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
final base = Uri.parse(normalizedBase);
return base.replace(
path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path',
queryParameters: query,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
final response = await http.post(
_uri(path),
headers: _headers,
body: jsonEncode(body),
);
return _decode(response);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
final response = await http.get(_uri(path, query), headers: _headers);
return _decode(response);
}
Map<String, dynamic> _decode(http.Response response) {
final decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded is Map<String, dynamic> ? decoded : {'data': decoded};
}
final message = decoded is Map ? decoded['message'] : null;
throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.');
}
}
class ApiException implements Exception {
ApiException(this.message);
final String message;
}
class SessionStore {
static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url';
static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<String> baseUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl;
}
static Future<SessionData> session() async {
final prefs = await SharedPreferences.getInstance();
return SessionData(
token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
);
}
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<void> saveBaseUrl(String baseUrl) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
}
}
class SessionData {
const SessionData({this.token, this.baseUrl = kDefaultBaseUrl});
final String? token;
final String baseUrl;
}
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'/+$'), '');
}
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _baseUrl = TextEditingController(text: kDefaultBaseUrl);
final _username = TextEditingController();
final _password = TextEditingController();
bool _obscure = true;
bool _loading = false;
bool _pinging = false;
String? _pingStatus;
@override
void initState() {
super.initState();
SessionStore.baseUrl().then((value) {
if (!mounted) {
return;
}
_baseUrl.text = value;
});
}
@override
void dispose() {
_baseUrl.dispose();
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _loading = true);
try {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
await SessionStore.saveBaseUrl(serverUrl);
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.saveToken(token);
if (!mounted) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl),
),
);
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Tidak bisa terhubung ke server MyLIS.');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _ping() async {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
if (serverUrl.isEmpty) {
return;
}
setState(() {
_pinging = true;
_pingStatus = null;
});
try {
await SessionStore.saveBaseUrl(serverUrl);
final data = await ApiClient(baseUrl: serverUrl).get('api/mobile/ping');
final appName = data['application']?.toString();
setState(() {
_baseUrl.text = serverUrl;
_pingStatus = appName == null || appName.isEmpty
? 'Server bisa diakses.'
: 'Server bisa diakses: $appName';
});
} on ApiException catch (error) {
setState(() => _pingStatus = error.message);
} catch (_) {
setState(() => _pingStatus = 'Koneksi server gagal.');
} 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: [
const Icon(
Icons.biotech_outlined,
size: 72,
color: Color(0xFF0F766E),
),
const SizedBox(height: 16),
Text(
'MyLIS Mobile',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
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: 'URL Laravel',
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(
'Default: $kDefaultBaseUrl',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.black45),
),
],
),
),
),
),
),
),
);
}
}
class DashboardScreen extends StatefulWidget {
const DashboardScreen({
super.key,
required this.token,
required this.baseUrl,
});
final String token;
final String baseUrl;
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/dashboard');
}
void _reload() {
setState(() => _future = _api.get('api/mobile/dashboard'));
}
Future<void> _logout() async {
await SessionStore.clear();
if (!mounted) {
return;
}
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
IconButton(
onPressed: _logout,
icon: const Icon(Icons.logout),
tooltip: 'Logout',
),
],
),
body: RefreshIndicator(
onRefresh: () async => _reload(),
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final 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']);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
HeaderPanel(user: user, summary: summary),
const SizedBox(height: 16),
SectionTitle(
title: 'Early Warning Sistem',
actionLabel:
'${warnings.fold<int>(0, (total, item) => total + (asMap(item)['total'] as num? ?? 0).toInt())} kasus',
),
if (warnings.isEmpty)
const EmptyPanel(text: 'Tidak ada data Early Warning.')
else
...warnings.map(
(group) => EarlyWarningGroupCard(
group: asMap(group),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
const SizedBox(height: 18),
const SectionTitle(title: 'List Pemeriksaan'),
...books.map(
(book) => BookTile(
book: asMap(book),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
],
);
},
),
),
);
}
}
class HeaderPanel extends StatelessWidget {
const HeaderPanel({super.key, required this.user, required this.summary});
final Map<String, dynamic> user;
final Map<String, dynamic> 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: [
Text(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.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<String, dynamic> 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,
});
final Map<String, dynamic> book;
final String token;
final String baseUrl;
@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: (_) => ExaminationListScreen(
token: token,
baseUrl: baseUrl,
master: book['master'].toString(),
title: book['label']?.toString() ?? 'Pemeriksaan',
),
),
);
},
),
);
}
}
class ExaminationListScreen extends StatefulWidget {
const ExaminationListScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
});
final String token;
final String baseUrl;
final String master;
final String title;
@override
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
}
class _ExaminationListScreenState extends State<ExaminationListScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
final query = _search.text.trim();
return _api.get(
'api/mobile/books/${widget.master}/examinations',
query.isEmpty ? null : {'search': query},
);
}
void _reload() {
setState(() => _future = _load());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _reload(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'Cari no lab, RM, pasien, order',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _reload,
icon: const Icon(Icons.arrow_forward),
tooltip: 'Cari',
),
),
),
),
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final items = asList(snapshot.data!['items']);
if (items.isEmpty) {
return const EmptyPanel(text: 'Tidak ada pemeriksaan aktif.');
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
itemCount: items.length,
itemBuilder: (context, index) => ExaminationCard(
item: asMap(items[index]),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
);
},
),
),
],
),
);
}
}
class ExaminationCompactTile extends StatelessWidget {
const ExaminationCompactTile({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(context, item, token, baseUrl),
);
}
}
class ExaminationCard extends StatelessWidget {
const ExaminationCard({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 8),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
),
Text(
item['reques']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.schedule, size: 16, color: Colors.black54),
const SizedBox(width: 6),
Expanded(
child: Text(
item['daftar']?.toString() ?? '-',
style: const TextStyle(color: Colors.black54),
),
),
TextButton.icon(
onPressed: () => _openDetail(context, item, token, baseUrl),
icon: const Icon(Icons.edit_note),
label: const Text('Expertise'),
),
],
),
],
),
),
),
);
}
}
Future<void> _openDetail(
BuildContext context,
Map<String, dynamic> item,
String token,
String baseUrl,
) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationDetailScreen(
token: token,
baseUrl: baseUrl,
id: (item['id'] as num).toInt(),
),
),
);
}
class ExaminationDetailScreen extends StatefulWidget {
const ExaminationDetailScreen({
super.key,
required this.token,
required this.baseUrl,
required this.id,
});
final String token;
final String baseUrl;
final int id;
@override
State<ExaminationDetailScreen> createState() =>
_ExaminationDetailScreenState();
}
class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/examinations/${widget.id}');
}
void _reload() {
setState(() => _future = _api.get('api/mobile/examinations/${widget.id}'));
}
Future<void> _launch(String? url) async {
if (url == null || url.isEmpty) {
return;
}
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Halaman tidak bisa dibuka.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Detail Pemeriksaan'),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Cek status',
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final item = asMap(data['item']);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
),
StatusPill(
status: item['status']?.toString() ?? 'NEW',
),
],
),
const SizedBox(height: 16),
DetailRow(label: 'No. Lab', value: item['nofoto']),
DetailRow(label: 'No. RM', value: item['noregister']),
DetailRow(label: 'Order', value: item['reques']),
DetailRow(
label: 'Asal Pasien',
value: item['asalpasien'],
),
DetailRow(label: 'Ruangan', value: item['ruangan']),
DetailRow(
label: 'Dokter Pengirim',
value: item['klinisi'] ?? item['nmdokter'],
),
DetailRow(label: 'Spesimen', value: item['nm_spesimen']),
DetailRow(label: 'Tanggal Daftar', value: item['daftar']),
DetailRow(
label: 'Tanggal Sampel',
value: item['tanggalsampel'],
),
DetailRow(
label: 'Cara Pengambilan',
value: item['pengambilan'],
),
DetailRow(
label: 'Asal Pengambilan',
value: item['asalpengirim'],
),
DetailRow(label: 'Alamat', value: item['alamatpasien']),
],
),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _launch(data['expertise_url']?.toString()),
icon: const Icon(Icons.edit_document),
label: const Text('Expertise'),
),
OutlinedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.fact_check_outlined),
label: const Text('Cek Status'),
),
if (data['result_url'] != null)
TextButton.icon(
onPressed: () => _launch(data['result_url']?.toString()),
icon: const Icon(Icons.description_outlined),
label: const Text('Preview Hasil'),
),
],
);
},
),
);
}
}
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 lower = status.toLowerCase();
final color = lower.contains('selesai')
? const Color(0xFF15803D)
: lower.contains('dibatalkan')
? const Color(0xFF6B7280)
: lower.contains('warning') || lower.contains('target')
? const Color(0xFFDC2626)
: const Color(0xFF0F766E);
return BadgeLabel(text: status, color: color);
}
}
class BadgeLabel extends StatelessWidget {
const BadgeLabel({super.key, required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
}
class EmptyPanel extends StatelessWidget {
const EmptyPanel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(text, style: const TextStyle(color: Colors.black54)),
),
);
}
}
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 42, color: Color(0xFFDC2626)),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
],
),
),
);
}
}
class LoadingScreen extends StatelessWidget {
const LoadingScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
Map<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return <String, dynamic>{};
}
List<dynamic> asList(Object? value) => value is List ? value : <dynamic>[];
String _message(Object? error) => error is ApiException
? error.message
: 'Terjadi kesalahan saat memuat data.';