1275 lines
38 KiB
Dart
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.';
|