Files
lis/mylis/lib/screens/login/login_screen.dart
T
2026-07-22 16:36:21 +07:00

305 lines
11 KiB
Dart

part of '../../app/app.dart';
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.session().then((value) {
if (!mounted) {
return;
}
_baseUrl.text = value.baseUrl;
_username.text = value.username ?? '';
_password.text = value.password ?? '';
});
}
@override
void dispose() {
_baseUrl.dispose();
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _loading = true);
try {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
final api = ApiClient(baseUrl: serverUrl);
final data = await api.post('api/mobile/login', {
'username': _username.text.trim(),
'password': _password.text,
});
final token = data['token']?.toString();
if (token == null || token.isEmpty) {
throw ApiException('Token login tidak diterima dari server.');
}
await SessionStore.saveLogin(
baseUrl: serverUrl,
token: token,
username: _username.text.trim(),
password: _password.text,
);
if (!mounted) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl),
),
);
} on ApiException catch (error) {
_showMessage(error.message);
} catch (error, stackTrace) {
debugPrint('Login MyLIS gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
_showMessage('Tidak bisa terhubung ke server MyLIS. Penyebab: $error');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<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 (error, stackTrace) {
debugPrint('Ping MyLIS gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
setState(() => _pingStatus = 'Koneksi server gagal. Penyebab: $error');
} finally {
if (mounted) setState(() => _pinging = false);
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Image.asset(
kAppLogoAsset,
height: 78,
fit: BoxFit.contain,
),
),
const SizedBox(width: 18),
Container(
width: 1,
height: 58,
color: const Color(0xFFE2E8F0),
),
const SizedBox(width: 18),
Flexible(
child: Image.asset(
kHospitalLogoAsset,
height: 78,
fit: BoxFit.contain,
),
),
],
),
const SizedBox(height: 18),
Text(
kHospitalName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF0F766E),
),
),
const SizedBox(height: 8),
Text(
kAppLongName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Login petugas',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.black54),
),
const SizedBox(height: 32),
TextFormField(
controller: _baseUrl,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link),
labelText: 'IP Server',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Ping server',
onPressed: _pinging ? null : _ping,
icon: _pinging
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.network_ping),
),
),
validator: (value) {
final url = normalizeBaseUrl(value ?? '');
final uri = Uri.tryParse(url);
return uri == null ||
!uri.hasScheme ||
!uri.hasAuthority
? 'URL Laravel tidak valid'
: null;
},
),
if (_pingStatus != null) ...[
const SizedBox(height: 8),
Text(
_pingStatus!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _pingStatus!.contains('bisa diakses')
? const Color(0xFF15803D)
: const Color(0xFFDC2626),
),
),
],
const SizedBox(height: 14),
TextFormField(
controller: _username,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.person_outline),
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Username wajib diisi'
: null,
),
const SizedBox(height: 14),
TextFormField(
controller: _password,
obscureText: _obscure,
onFieldSubmitted: (_) => _login(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_outline),
labelText: 'Password',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: _obscure
? 'Tampilkan password'
: 'Sembunyikan password',
onPressed: () => setState(() => _obscure = !_obscure),
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => value == null || value.isEmpty
? 'Password wajib diisi'
: null,
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: _loading ? null : _login,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('Login'),
),
const SizedBox(height: 18),
Text(
'Duidev Software House | CV Swandhana',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.black45),
),
const SizedBox(height: 18),
Text(
'Copyright @ 2026',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.black54,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
),
);
}
}