From cb75b69a3cab49eb05bedbc622db6a4124aa862a Mon Sep 17 00:00:00 2001 From: Samsularifin01 <158025491+Samsularifin01@users.noreply.github.com> Date: Wed, 20 May 2026 13:44:23 +0700 Subject: [PATCH] connect api to login feature, and add vibration feature when an object is detected --- android/app/src/main/AndroidManifest.xml | 1 + .../smart_vision_assist/MainActivity.kt | 68 ++++- lib/models/login_request.dart | 16 ++ lib/models/login_response.dart | 21 ++ lib/screens/camera_screen.dart | 242 +++++++++++------- lib/screens/login_screen.dart | 239 ++++++++++------- lib/services/api_config.dart | 2 +- lib/services/auth_service.dart | 59 ++++- lib/services/vibration_service.dart | 31 +++ 9 files changed, 487 insertions(+), 192 deletions(-) create mode 100644 lib/models/login_request.dart create mode 100644 lib/models/login_response.dart create mode 100644 lib/services/vibration_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a4f0ac2..cd6a56f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + when (call.method) { + "vibrate" -> { + val milliseconds = call.argument("milliseconds") ?: 7000 + result.success(vibrate(milliseconds.toLong())) + } + "stop" -> { + stopVibration() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun vibrate(milliseconds: Long): Boolean { + val vibrator = getVibrator() ?: return false + + if (!vibrator.hasVibrator()) { + return false + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot( + milliseconds, + VibrationEffect.DEFAULT_AMPLITUDE + ) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(milliseconds) + } + + return true + } + + private fun stopVibration() { + getVibrator()?.cancel() + } + + private fun getVibrator(): Vibrator? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + } +} diff --git a/lib/models/login_request.dart b/lib/models/login_request.dart new file mode 100644 index 0000000..01be652 --- /dev/null +++ b/lib/models/login_request.dart @@ -0,0 +1,16 @@ +class LoginRequest { + final String email; + final String password; + + const LoginRequest({ + required this.email, + required this.password, + }); + + Map toJson() { + return { + "email": email, + "password": password, + }; + } +} diff --git a/lib/models/login_response.dart b/lib/models/login_response.dart new file mode 100644 index 0000000..405fd2f --- /dev/null +++ b/lib/models/login_response.dart @@ -0,0 +1,21 @@ +class LoginResponse { + final String status; + final String message; + final String? token; + + const LoginResponse({ + required this.status, + required this.message, + this.token, + }); + + factory LoginResponse.fromJson(Map json) { + return LoginResponse( + status: json["status"]?.toString() ?? "error", + message: json["message"]?.toString() ?? "", + token: json["token"]?.toString(), + ); + } + + bool get isSuccess => status.toLowerCase() == "success"; +} diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index 0a22f16..3d1a6dd 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -5,10 +5,12 @@ import 'dart:ui' as ui; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../services/tts_service.dart'; +import '../services/vibration_service.dart'; import '../services/yolo_detection_service.dart'; +import 'login_screen.dart'; class CameraScreen extends StatefulWidget { const CameraScreen({super.key}); @@ -23,6 +25,7 @@ class _CameraScreenState extends State { final YoloDetectionService _detectionService = YoloDetectionService(); final TTSService _tts = TTSService(); + final VibrationService _vibrationService = VibrationService(); bool isRecording = false; bool isDetecting = false; @@ -31,7 +34,8 @@ class _CameraScreenState extends State { Size? detectionFrameSize; Timer? _detectionTimer; Timer? _vibrationTimer; - bool _isVibrating = false; + + bool _isLoggingOut = false; String _lastSpokenMessage = ""; DateTime? _lastSpokenAt; @@ -101,7 +105,8 @@ class _CameraScreenState extends State { _detectionTimer = null; _vibrationTimer?.cancel(); _vibrationTimer = null; - _isVibrating = false; + await _vibrationService.stop(); + if (!mounted) return; setState(() { @@ -114,6 +119,43 @@ class _CameraScreenState extends State { await _tts.speak("Deteksi dihentikan"); } + Future logout() async { + if (_isLoggingOut) { + return; + } + + _isLoggingOut = true; + + if (isRecording || + isDetecting || + _detectionTimer != null || + _vibrationTimer != null) { + await stopRecording(); + } else { + _detectionTimer?.cancel(); + _detectionTimer = null; + _vibrationTimer?.cancel(); + _vibrationTimer = null; + await _vibrationService.stop(); + + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove("auth_token"); + + if (!mounted) { + return; + } + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen(), + ), + (route) => false, + ); + } + Future _detectCurrentFrame() async { final CameraController? cameraController = controller; if (cameraController == null || @@ -196,7 +238,7 @@ class _CameraScreenState extends State { return; } - _vibrateForTwoSeconds(); + await _vibrateForSevenSeconds(); final Map objectCounts = {}; for (final YoloDetection object in response.objects) { @@ -212,29 +254,16 @@ class _CameraScreenState extends State { await _speakOnce("Terdeteksi $objectSummary"); } - void _vibrateForTwoSeconds() { - if (_isVibrating) { - return; - } - - _isVibrating = true; - int vibrationCount = 0; - const int maxVibrationCount = 8; - const Duration vibrationInterval = Duration(milliseconds: 250); - - HapticFeedback.vibrate(); - vibrationCount++; - + Future _vibrateForSevenSeconds() async { _vibrationTimer?.cancel(); - _vibrationTimer = Timer.periodic(vibrationInterval, (Timer timer) { - if (!mounted || !isRecording || vibrationCount >= maxVibrationCount) { - timer.cancel(); - _isVibrating = false; - return; - } + + const Duration vibrationDuration = Duration(seconds: 7); - HapticFeedback.vibrate(); - vibrationCount++; + await _vibrationService.vibrateFor(vibrationDuration); + + _vibrationTimer = Timer(vibrationDuration, () { + _vibrationTimer = null; + }); } @@ -256,6 +285,7 @@ class _CameraScreenState extends State { void dispose() { _detectionTimer?.cancel(); _vibrationTimer?.cancel(); + unawaited(_vibrationService.stop()); controller?.dispose(); super.dispose(); } @@ -291,94 +321,108 @@ class _CameraScreenState extends State { @override Widget build(BuildContext context) { if (controller == null || !controller!.value.isInitialized) { - return Scaffold( - body: Center( - child: statusMessage == "Tekan tombol rekam untuk mulai deteksi" - ? const CircularProgressIndicator() - : Padding( - padding: const EdgeInsets.all(24), - child: Text( - statusMessage, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (!didPop) { + await logout(); + } + }, + child: Scaffold( + body: Center( + child: statusMessage == "Tekan tombol rekam untuk mulai deteksi" + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(24), + child: Text( + statusMessage, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), ), - ), + ), ), ); } - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: buildCameraPreview(), - ), - Positioned.fill( - child: IgnorePointer( - child: _BoundingBoxOverlay( - imageSize: detectionFrameSize, + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (!didPop) { + await logout(); + } + }, + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: buildCameraPreview(), + ), + Positioned.fill( + child: IgnorePointer( + child: _BoundingBoxOverlay( + imageSize: detectionFrameSize, + objects: detectedObjects, + ), + ), + ), + Positioned( + top: 48, + left: 16, + right: 16, + child: _DetectionStatusPanel( + isRecording: isRecording, + isDetecting: isDetecting, + statusMessage: statusMessage, objects: detectedObjects, ), ), - ), - Positioned( - top: 48, - left: 16, - right: 16, - child: _DetectionStatusPanel( - isRecording: isRecording, - isDetecting: isDetecting, - statusMessage: statusMessage, - objects: detectedObjects, - ), - ), - Positioned( - bottom: 40, - right: 30, - child: Semantics( - label: "Kembali", - button: true, - child: FloatingActionButton( - backgroundColor: Colors.black, - onPressed: () { - Navigator.pop(context); - }, - child: const Icon(Icons.arrow_back), + Positioned( + bottom: 40, + right: 30, + child: Semantics( + label: "Kembali", + button: true, + child: FloatingActionButton( + backgroundColor: Colors.black, + onPressed: logout, + child: const Icon(Icons.arrow_back), + ), ), ), - ), - Positioned( - bottom: 30, - left: MediaQuery.of(context).size.width / 2 - 35, - child: Semantics( - label: isRecording ? "Stop rekam" : "Mulai rekam", - button: true, - child: GestureDetector( - onTap: () { - if (isRecording) { - stopRecording(); - } else { - startRecording(); - } - }, - child: Container( - width: 70, - height: 70, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isRecording ? Colors.red : Colors.white, - border: Border.all(color: Colors.grey, width: 4), - ), - child: Icon( - isRecording ? Icons.stop : Icons.circle, - color: isRecording ? Colors.white : Colors.red, - size: 30, + Positioned( + bottom: 30, + left: MediaQuery.of(context).size.width / 2 - 35, + child: Semantics( + label: isRecording ? "Stop rekam" : "Mulai rekam", + button: true, + child: GestureDetector( + onTap: () { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }, + child: Container( + width: 70, + height: 70, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRecording ? Colors.red : Colors.white, + border: Border.all(color: Colors.grey, width: 4), + ), + child: Icon( + isRecording ? Icons.stop : Icons.circle, + color: isRecording ? Colors.white : Colors.red, + size: 30, + ), ), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 45b6a60..c663d3e 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,42 +1,105 @@ import 'package:flutter/material.dart'; +import 'package:smart_vision_assist/screens/camera_screen.dart'; import '../widgets/custom_button.dart'; import '../widgets/custom_textfield.dart'; import '../services/tts_service.dart'; import '../services/auth_service.dart'; +import '../models/login_request.dart'; import '../utils/colors.dart'; -import 'home_screen.dart'; import 'signup_screen.dart'; import 'forgot_password_screen.dart'; -import 'camera_screen.dart'; -class LoginScreen extends StatelessWidget { + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); final TTSService tts = TTSService(); final AuthService auth = AuthService(); - LoginScreen({super.key}); + bool isLoading = false; - // 🔐 LOGIN - void handleLogin(BuildContext context) { - bool success = auth.login( - emailController.text, - passwordController.text, + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + tts.speak("Silakan login"); + }); + } + + @override + void dispose() { + emailController.dispose(); + passwordController.dispose(); + super.dispose(); + } + + Future handleLogin(BuildContext context) async { + if (isLoading) { + return; + } + + final String email = emailController.text.trim(); + final String password = passwordController.text; + + if (email.isEmpty) { + _showMessage("Email wajib diisi"); + return; + } + + if (!email.contains("@")) { + _showMessage("Email tidak valid"); + return; + } + + if (password.isEmpty) { + _showMessage("Password wajib diisi"); + return; + } + + setState(() { + isLoading = true; + }); + + final response = await auth.login( + LoginRequest( + email: email, + password: password, + ), ); - if (success) { - tts.speak("Login berhasil"); - Navigator.push( + if (!context.mounted) { + return; + } + + setState(() { + isLoading = false; + }); + + if (response.isSuccess && response.token != null) { + _showMessage( + response.message.isNotEmpty ? response.message : "Login berhasil", + ); + Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => HomeScreen()), + MaterialPageRoute(builder: (_) => const CameraScreen()), ); } else { - tts.speak("Login gagal, periksa email dan password"); + _showMessage( + response.message.isNotEmpty + ? response.message + : "Login gagal, periksa email dan password", + ); } } - // 🔑 FORGOT PASSWORD void handleForgotPassword(BuildContext context) { tts.speak("Menu lupa password"); Navigator.push( @@ -45,94 +108,96 @@ class LoginScreen extends StatelessWidget { ); } - // 🆕 SIGN UP void handleSignUp(BuildContext context) { tts.speak("Menu pendaftaran akun"); Navigator.push( context, - MaterialPageRoute(builder: (_) => SignUpScreen()), + MaterialPageRoute(builder: (_) => const SignUpScreen()), + ); + } + + void _showMessage(String message) { + tts.speak(message); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), ); } @override Widget build(BuildContext context) { - tts.speak("Silakan login"); - return Scaffold( backgroundColor: AppColors.background, - body: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 🔷 TITLE - Text( - "Object Detector Assist", - style: TextStyle( - color: AppColors.text, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - - SizedBox(height: 40), - - // 📧 EMAIL - CustomTextField( - label: "Email", - controller: emailController, - ), - - SizedBox(height: 20), - - // 🔒 PASSWORD - CustomTextField( - label: "Password", - controller: passwordController, - obscure: true, - ), - - SizedBox(height: 30), - - // 🔘 LOGIN BUTTON - CustomButton( - text: "Buka Kamera", - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => CameraScreen()), - ); - }, - ), - - SizedBox(height: 15), - - // 🔹 FORGOT PASSWORD - Semantics( - button: true, - label: "Lupa password", - child: TextButton( - onPressed: () => handleForgotPassword(context), - child: Text( - "Forgot Password?", - style: TextStyle(color: Colors.blue), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Object Detector Assist", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.text, + fontSize: 24, + fontWeight: FontWeight.bold, ), ), - ), - // 🔹 SIGN UP - Semantics( - button: true, - label: "Daftar akun baru", - child: TextButton( - onPressed: () => handleSignUp(context), - child: Text( - "Sign Up", - style: TextStyle(color: Colors.yellow), + const SizedBox(height: 40), + + CustomTextField( + label: "Email", + controller: emailController, + keyboardType: TextInputType.emailAddress, + ), + + const SizedBox(height: 20), + + CustomTextField( + label: "Password", + controller: passwordController, + obscure: true, + ), + + const SizedBox(height: 30), + + CustomButton( + text: isLoading ? "Memproses..." : "Login", + onPressed: () => handleLogin(context), + ), + + const SizedBox(height: 15), + + Semantics( + button: true, + label: "Lupa password", + child: TextButton( + onPressed: () => handleForgotPassword(context), + child: const Text( + "Forgot Password?", + style: TextStyle(color: Colors.blue), + ), ), ), - ), - ], + + Semantics( + button: true, + label: "Daftar akun baru", + child: TextButton( + onPressed: () => handleSignUp(context), + child: const Text( + "Sign Up", + style: TextStyle(color: Colors.yellow), + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/services/api_config.dart b/lib/services/api_config.dart index 85bf63b..fc0bb8b 100644 --- a/lib/services/api_config.dart +++ b/lib/services/api_config.dart @@ -5,7 +5,7 @@ class ApiConfig { static const bool useAndroidEmulator = false; // Ganti IP ini dengan IP laptop/PC Anda saat menggunakan HP fisik (ADB). - static const String deviceHost = "192.168.0.31:8000"; + static const String deviceHost = "192.168.18.14:8000"; static String get baseUrl { if (Platform.isAndroid && useAndroidEmulator) { diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index dc1e423..db5c49a 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,6 +1,57 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/login_request.dart'; +import '../models/login_response.dart'; +import 'api_config.dart'; + class AuthService { - bool login(String email, String password) { - // dummy login - return email == "admin@gmail.com" && password == "1234"; + static const String _tokenKey = "auth_token"; + + Future login(LoginRequest request) async { + final Uri url = Uri.parse("${ApiConfig.baseUrl}/login"); + + try { + final http.Response response = await http.post( + url, + headers: {"Content-Type": "application/json"}, + body: jsonEncode(request.toJson()), + ); + + if (response.body.isEmpty) { + return LoginResponse( + status: "error", + message: "Respons server kosong (status ${response.statusCode})", + ); + } + + Map jsonBody; + try { + jsonBody = jsonDecode(response.body) as Map; + } catch (_) { + return LoginResponse( + status: "error", + message: "Respons server tidak valid (status ${response.statusCode})", + ); + } + + final LoginResponse loginResponse = LoginResponse.fromJson(jsonBody); + + if (loginResponse.isSuccess && loginResponse.token != null) { + await _saveToken(loginResponse.token!); + } + + return loginResponse; + } catch (error) { + return const LoginResponse( + status: "error", + message: "Gagal terhubung ke server", + ); + } } -} \ No newline at end of file + + Future _saveToken(String token) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + } +} diff --git a/lib/services/vibration_service.dart b/lib/services/vibration_service.dart new file mode 100644 index 0000000..30c4492 --- /dev/null +++ b/lib/services/vibration_service.dart @@ -0,0 +1,31 @@ +import 'package:flutter/services.dart'; + +class VibrationService { + static const MethodChannel _channel = + MethodChannel("smart_vision_assist/vibration"); + + Future vibrateFor(Duration duration) async { + try { + final bool? didVibrate = await _channel.invokeMethod( + "vibrate", + {"milliseconds": duration.inMilliseconds}, + ); + + if (didVibrate == true) { + return; + } + } catch (_) { + // Fallback untuk perangkat/platform yang tidak mendukung channel native. + } + + await HapticFeedback.vibrate(); + } + + Future stop() async { + try { + await _channel.invokeMethod("stop"); + } catch (_) { + // Tidak perlu aksi tambahan jika platform tidak mendukung stop vibration. + } + } +}