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.
+ }
+ }
+}