ADD ALOT OF POLISHING AND UPDATES...

This commit is contained in:
Wowieee4
2026-05-26 05:52:12 +07:00
parent 2e3ecdf1d1
commit b8ad8df993
53 changed files with 3194 additions and 3860 deletions
@@ -12,19 +12,34 @@ class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
@override
Future<Either<Failure, UserEntity>> login(String email, String password) async {
Future<Either<Failure, UserEntity>> login(
String email, String password) async {
try {
// 1. Suruh data source nembak API
final userModel = await remoteDataSource.login(email, password);
// 2. Simpan token langsung ke HP biar UI gausah ribet
await secureStorage.saveToken(userModel.token);
// 3. Balikin data sukses (Right)
return Right(userModel);
} catch (e) {
// 4. Balikin pesan error (Left)
return Left(ServerFailure(e.toString().replaceAll('Exception: ', '')));
return Left(ServerFailure(_safeAuthFailure(e)));
}
}
}
}
String _safeAuthFailure(Object error) {
final message = error.toString().replaceFirst(RegExp(r'^\w+:\s*'), '').trim();
final lower = message.toLowerCase();
final technical = lower.contains('exception') ||
lower.contains('typeerror') ||
lower.contains('stacktrace') ||
lower.contains('instance of') ||
lower.contains('package:');
if (message.isEmpty || technical) {
return 'Login gagal. Periksa email dan password kamu.';
}
return message;
}
@@ -2,7 +2,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -11,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.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/offline_queue_service.dart';
import '../../core/services/tts_service.dart';
@@ -65,27 +65,27 @@ class _LoginScreenState extends State<LoginScreen> {
return;
}
setState(() => _loading = true);
try {
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));
} on DioException catch (e) {
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal'));
} catch (e) {
_snack(context, 'Login gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
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 sebagai Guardian atau User.',
subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -147,42 +147,134 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 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(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@@ -219,16 +311,14 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) {
Future.microtask(() async {
try {
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('Post-login services skipped: $e');
}
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
}).catchError((Object e) {
debugPrint('Post-login services skipped: $e');
});
}
@@ -238,19 +328,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message)));
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 401) {
return 'Email atau password salah.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
}
@@ -1 +1 @@
export '../../screens.dart';
export '../login_screen.dart';
@@ -1,11 +1,11 @@
// ignore_for_file: use_build_context_synchronously
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
@@ -52,26 +52,26 @@ class _RegisterScreenState extends State<RegisterScreen> {
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.from(res.data['data'] as Map);
if (!mounted) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pending_login_email', _email.text.trim());
await _showRegisterSuccess(context, data);
if (mounted) context.go('/login');
} on DioException catch (e) {
_snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal'));
} catch (e) {
_snack(context, 'Registrasi gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.from(res.data['data'] as Map);
if (!mounted) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pending_login_email', _email.text.trim());
await _showRegisterSuccess(context, data);
if (mounted) context.go('/login');
},
onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
);
if (mounted) setState(() => _loading = false);
}
@override
@@ -81,7 +81,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
subtitle: _step == 0
? 'Who are you in the WalkGuide system?'
: _role == 'USER'
? 'User akan mendapat Unique ID untuk pairing.'
? 'User bisa membuat Pairing Code sementara setelah login.'
: 'Guardian dapat monitor dan konfigurasi User.',
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
);
@@ -298,42 +298,134 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 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(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@@ -348,12 +440,12 @@ Future<void> _showRegisterSuccess(
final uniqueId = data['uniqueUserId']?.toString();
final message = uniqueId == null || uniqueId.isEmpty
? 'Registrasi berhasil. Silakan login.'
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.';
: 'Registrasi berhasil.\n\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
_snack(
context,
uniqueId == null
? 'Registrasi berhasil.'
: 'Registrasi berhasil. ID: $uniqueId');
: 'Registrasi berhasil. Buat Pairing Code setelah login.');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -375,19 +467,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message)));
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 409) {
return 'Email sudah terdaftar. Gunakan email lain atau login.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di HP pakai IP server, bukan localhost.';
}
return fallback;
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
@@ -51,29 +52,33 @@ class _SplashScreenState extends State<SplashScreen>
}
Future<void> _route() async {
try {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final routed = await runFriendlyAction(
() async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
if (!mounted) return;
if (!mounted) return;
if (token == null || role == null) {
context.go('/login');
return;
}
if (token == null || role == null) {
context.go('/login');
return;
}
// Auto-login: arahkan ke home sesuai role.
context.go(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide');
} catch (_) {
if (mounted) context.go('/login');
}
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: '/user/walkguide');
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
}
@override