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.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: [ Center( child: Image.asset( kAppLogoAsset, height: 96, fit: BoxFit.contain, ), ), const SizedBox(height: 16), Text( kAppLongName, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), Center( child: Image.asset( kHospitalLogoAsset, height: 54, fit: BoxFit.contain, ), ), const SizedBox(height: 8), Text( kHospitalName, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: const Color(0xFF0F766E), ), ), 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: '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), ), ], ), ), ), ), ), ), ); } }