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 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); }, ); } } class ApiClient { ApiClient({required this.baseUrl, this.token}); final String baseUrl; final 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 { final response = await http.post( _uri(path), headers: _headers, body: jsonEncode(body), ); return _decode(response); } Future> get( String path, [ Map? query, ]) async { final response = await http.get(_uri(path, query), headers: _headers); return _decode(response); } Map _decode(http.Response response) { final decoded = response.body.isEmpty ? {} : jsonDecode(response.body); if (response.statusCode >= 200 && response.statusCode < 300) { return decoded is Map ? 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 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, ); } static Future saveToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); } 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}); 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 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.baseUrl().then((value) { if (!mounted) { return; } _baseUrl.text = value; }); } @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); 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 _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 createState() => _DashboardScreenState(); } class _DashboardScreenState 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/dashboard'); } 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())); } @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>( 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(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 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: [ 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 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 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 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); _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( 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>( 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 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 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 _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(), ), ), ); } 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.')), ); } } @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>( 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 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.';