390 lines
15 KiB
Dart
390 lines
15 KiB
Dart
// ignore_for_file: use_build_context_synchronously
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../app/app_cubit.dart';
|
|
import '../../app/router.dart';
|
|
import '../../app/injection_container.dart';
|
|
import '../../core/constants/app_constants.dart';
|
|
import '../../core/errors/friendly_error.dart';
|
|
import '../../core/network/api_client.dart';
|
|
import '../../core/services/fcm_service.dart';
|
|
import '../../core/services/incoming_call_polling_service.dart';
|
|
import '../../core/services/offline_queue_service.dart';
|
|
import '../../core/services/tts_service.dart';
|
|
import '../../core/services/websocket_service.dart';
|
|
import '../../core/storage/secure_storage.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LoginScreen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({super.key});
|
|
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
bool _loading = false;
|
|
bool _showPassword = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadPendingLoginEmail();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadPendingLoginEmail() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final pendingEmail = prefs.getString('pending_login_email');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
if (pendingEmail != null && pendingEmail.isNotEmpty) {
|
|
_email.text = pendingEmail;
|
|
}
|
|
});
|
|
await prefs.remove('pending_login_email');
|
|
}
|
|
|
|
Future<void> _login() async {
|
|
if (_email.text.trim().isEmpty || _password.text.isEmpty) {
|
|
_snack(context, 'Isi email dan password dulu.');
|
|
return;
|
|
}
|
|
setState(() => _loading = true);
|
|
await runFriendlyAction(
|
|
() async {
|
|
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
|
|
'email': _email.text.trim(),
|
|
'password': _password.text,
|
|
});
|
|
await _saveAuthAndRoute(
|
|
context, Map<String, dynamic>.from(res.data['data'] as Map));
|
|
},
|
|
onError: (message) => _snack(context, message),
|
|
fallback: 'Login gagal. Periksa email dan password kamu.',
|
|
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
|
);
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _AuthFrame(
|
|
title: 'Sign in',
|
|
subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: _email,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Email',
|
|
prefixIcon: Icon(Icons.email_outlined),
|
|
)),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _password,
|
|
obscureText: !_showPassword,
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) => _login(),
|
|
decoration: InputDecoration(
|
|
labelText: 'Password',
|
|
prefixIcon: const Icon(Icons.lock_outline),
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_showPassword ? Icons.visibility_off : Icons.visibility),
|
|
onPressed: () =>
|
|
setState(() => _showPassword = !_showPassword),
|
|
),
|
|
)),
|
|
const SizedBox(height: 18),
|
|
FilledButton.icon(
|
|
onPressed: _loading ? null : _login,
|
|
icon: _loading
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Icon(Icons.login),
|
|
label: const Text('Login'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => context.go('/register'),
|
|
child: const Text('Buat akun baru')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared private widgets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _AuthFrame extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final Widget child;
|
|
const _AuthFrame(
|
|
{required this.title, required this.subtitle, required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFEAF4FF),
|
|
body: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final compact =
|
|
constraints.maxWidth < 480 || constraints.maxHeight < 720;
|
|
return Stack(
|
|
children: [
|
|
const Positioned.fill(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: compact ? -70 : -90,
|
|
right: compact ? -70 : -60,
|
|
child: Container(
|
|
width: compact ? 180 : 260,
|
|
height: compact ? 180 : 260,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
Center(
|
|
child: SingleChildScrollView(
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 14 : 24,
|
|
compact ? 12 : 24,
|
|
compact ? 14 : 24,
|
|
20 + MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: 18, end: 0),
|
|
duration: const Duration(milliseconds: 520),
|
|
curve: Curves.easeOutCubic,
|
|
builder: (_, offset, child) => Opacity(
|
|
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
|
child: Transform.translate(
|
|
offset: Offset(0, offset),
|
|
child: child,
|
|
),
|
|
),
|
|
child: RepaintBoundary(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.96),
|
|
borderRadius:
|
|
BorderRadius.circular(compact ? 22 : 30),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.8)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF1E3A8A)
|
|
.withValues(alpha: 0.14),
|
|
blurRadius: compact ? 24 : 40,
|
|
offset: const Offset(0, 18),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
compact ? 18 : 24,
|
|
compact ? 18 : 26,
|
|
compact ? 18 : 24,
|
|
compact ? 18 : 24,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: compact ? 44 : 56,
|
|
height: compact ? 44 : 56,
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [
|
|
Color(0xFF2563EB),
|
|
Color(0xFF0891B2)
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Icon(Icons.navigation_rounded,
|
|
color: Colors.white,
|
|
size: compact ? 26 : 30),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: Text(
|
|
'WalkGuide',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFF0F172A),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: compact ? 14 : 16),
|
|
if (!compact)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEFF6FF),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.shield_outlined,
|
|
size: 14, color: Color(0xFF1D4ED8)),
|
|
SizedBox(width: 6),
|
|
Text(
|
|
'Secure Assistive Navigation',
|
|
style: TextStyle(
|
|
color: Color(0xFF1D4ED8),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!compact) const SizedBox(height: 18),
|
|
Text(
|
|
title,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.headlineMedium
|
|
?.copyWith(
|
|
fontSize: compact ? 26 : null,
|
|
fontWeight: FontWeight.w900,
|
|
color: const Color(0xFF0F172A),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Color(0xFF64748B),
|
|
height: 1.35,
|
|
),
|
|
),
|
|
SizedBox(height: compact ? 18 : 26),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (shared for login flow)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Future<void> _saveAuthAndRoute(
|
|
BuildContext context, Map<String, dynamic> data) async {
|
|
await sl<SecureStorage>().saveTokens(
|
|
accessToken: data['accessToken'],
|
|
refreshToken: data['refreshToken'],
|
|
role: data['role'],
|
|
userId: data['userId'].toString(),
|
|
displayName: data['displayName'],
|
|
uniqueUserId: data['uniqueUserId'],
|
|
);
|
|
final serverUrl = await AppConstants.getServerUrl();
|
|
if (serverUrl != null) {
|
|
context
|
|
.read<AppCubit>()
|
|
.setSession(role: data['role'], serverUrl: serverUrl);
|
|
_startPostLoginServices(serverUrl);
|
|
}
|
|
sl<TtsService>().speak('Selamat datang ${data['displayName'] ?? ''}');
|
|
if (context.mounted) {
|
|
context.go(data['role'] == 'ROLE_GUARDIAN'
|
|
? '/guardian/dashboard'
|
|
: '/user/walkguide');
|
|
}
|
|
}
|
|
|
|
void _startPostLoginServices(String serverUrl) {
|
|
Future.microtask(() async {
|
|
sl<IncomingCallPollingService>().start();
|
|
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
|
final ws = sl<WebSocketService>();
|
|
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
|
ws.subscribeCall((data) {
|
|
final type = data['type']?.toString();
|
|
if (type == 'INCOMING_CALL') {
|
|
appRouter.go('/incoming-call', extra: data);
|
|
}
|
|
});
|
|
await sl<OfflineQueueService>()
|
|
.syncPending(sl<ApiClient>())
|
|
.timeout(const Duration(seconds: 3));
|
|
}).catchError((Object e) {
|
|
debugPrint('Post-login services skipped: $e');
|
|
});
|
|
}
|
|
|
|
void _snack(BuildContext context, String message) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
}
|