connect api to login feature, and add vibration feature when an object is detected

This commit is contained in:
Samsularifin01 2026-05-20 13:44:23 +07:00
parent 7d7059d9f8
commit cb75b69a3c
9 changed files with 487 additions and 192 deletions

View File

@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<application <application
android:label="@string/app_name" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -1,5 +1,71 @@
package com.example.smart_vision_assist package com.example.smart_vision_assist
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() class MainActivity : FlutterActivity() {
private val vibrationChannel = "smart_vision_assist/vibration"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
vibrationChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"vibrate" -> {
val milliseconds = call.argument<Int>("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
}
}
}

View File

@ -0,0 +1,16 @@
class LoginRequest {
final String email;
final String password;
const LoginRequest({
required this.email,
required this.password,
});
Map<String, dynamic> toJson() {
return {
"email": email,
"password": password,
};
}
}

View File

@ -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<String, dynamic> json) {
return LoginResponse(
status: json["status"]?.toString() ?? "error",
message: json["message"]?.toString() ?? "",
token: json["token"]?.toString(),
);
}
bool get isSuccess => status.toLowerCase() == "success";
}

View File

@ -5,10 +5,12 @@ import 'dart:ui' as ui;
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/material.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/tts_service.dart';
import '../services/vibration_service.dart';
import '../services/yolo_detection_service.dart'; import '../services/yolo_detection_service.dart';
import 'login_screen.dart';
class CameraScreen extends StatefulWidget { class CameraScreen extends StatefulWidget {
const CameraScreen({super.key}); const CameraScreen({super.key});
@ -23,6 +25,7 @@ class _CameraScreenState extends State<CameraScreen> {
final YoloDetectionService _detectionService = YoloDetectionService(); final YoloDetectionService _detectionService = YoloDetectionService();
final TTSService _tts = TTSService(); final TTSService _tts = TTSService();
final VibrationService _vibrationService = VibrationService();
bool isRecording = false; bool isRecording = false;
bool isDetecting = false; bool isDetecting = false;
@ -31,7 +34,8 @@ class _CameraScreenState extends State<CameraScreen> {
Size? detectionFrameSize; Size? detectionFrameSize;
Timer? _detectionTimer; Timer? _detectionTimer;
Timer? _vibrationTimer; Timer? _vibrationTimer;
bool _isVibrating = false;
bool _isLoggingOut = false;
String _lastSpokenMessage = ""; String _lastSpokenMessage = "";
DateTime? _lastSpokenAt; DateTime? _lastSpokenAt;
@ -101,7 +105,8 @@ class _CameraScreenState extends State<CameraScreen> {
_detectionTimer = null; _detectionTimer = null;
_vibrationTimer?.cancel(); _vibrationTimer?.cancel();
_vibrationTimer = null; _vibrationTimer = null;
_isVibrating = false; await _vibrationService.stop();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -114,6 +119,43 @@ class _CameraScreenState extends State<CameraScreen> {
await _tts.speak("Deteksi dihentikan"); await _tts.speak("Deteksi dihentikan");
} }
Future<void> 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<void> _detectCurrentFrame() async { Future<void> _detectCurrentFrame() async {
final CameraController? cameraController = controller; final CameraController? cameraController = controller;
if (cameraController == null || if (cameraController == null ||
@ -196,7 +238,7 @@ class _CameraScreenState extends State<CameraScreen> {
return; return;
} }
_vibrateForTwoSeconds(); await _vibrateForSevenSeconds();
final Map<String, int> objectCounts = {}; final Map<String, int> objectCounts = {};
for (final YoloDetection object in response.objects) { for (final YoloDetection object in response.objects) {
@ -212,29 +254,16 @@ class _CameraScreenState extends State<CameraScreen> {
await _speakOnce("Terdeteksi $objectSummary"); await _speakOnce("Terdeteksi $objectSummary");
} }
void _vibrateForTwoSeconds() { Future<void> _vibrateForSevenSeconds() async {
if (_isVibrating) {
return;
}
_isVibrating = true;
int vibrationCount = 0;
const int maxVibrationCount = 8;
const Duration vibrationInterval = Duration(milliseconds: 250);
HapticFeedback.vibrate();
vibrationCount++;
_vibrationTimer?.cancel(); _vibrationTimer?.cancel();
_vibrationTimer = Timer.periodic(vibrationInterval, (Timer timer) {
if (!mounted || !isRecording || vibrationCount >= maxVibrationCount) { const Duration vibrationDuration = Duration(seconds: 7);
timer.cancel();
_isVibrating = false;
return;
}
HapticFeedback.vibrate(); await _vibrationService.vibrateFor(vibrationDuration);
vibrationCount++;
_vibrationTimer = Timer(vibrationDuration, () {
_vibrationTimer = null;
}); });
} }
@ -256,6 +285,7 @@ class _CameraScreenState extends State<CameraScreen> {
void dispose() { void dispose() {
_detectionTimer?.cancel(); _detectionTimer?.cancel();
_vibrationTimer?.cancel(); _vibrationTimer?.cancel();
unawaited(_vibrationService.stop());
controller?.dispose(); controller?.dispose();
super.dispose(); super.dispose();
} }
@ -291,94 +321,108 @@ class _CameraScreenState extends State<CameraScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (controller == null || !controller!.value.isInitialized) { if (controller == null || !controller!.value.isInitialized) {
return Scaffold( return PopScope(
body: Center( canPop: false,
child: statusMessage == "Tekan tombol rekam untuk mulai deteksi" onPopInvokedWithResult: (bool didPop, Object? result) async {
? const CircularProgressIndicator() if (!didPop) {
: Padding( await logout();
padding: const EdgeInsets.all(24), }
child: Text( },
statusMessage, child: Scaffold(
textAlign: TextAlign.center, body: Center(
style: const TextStyle(fontSize: 16), 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( return PopScope(
body: Stack( canPop: false,
children: [ onPopInvokedWithResult: (bool didPop, Object? result) async {
Positioned.fill( if (!didPop) {
child: buildCameraPreview(), await logout();
), }
Positioned.fill( },
child: IgnorePointer( child: Scaffold(
child: _BoundingBoxOverlay( body: Stack(
imageSize: detectionFrameSize, 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, objects: detectedObjects,
), ),
), ),
), Positioned(
Positioned( bottom: 40,
top: 48, right: 30,
left: 16, child: Semantics(
right: 16, label: "Kembali",
child: _DetectionStatusPanel( button: true,
isRecording: isRecording, child: FloatingActionButton(
isDetecting: isDetecting, backgroundColor: Colors.black,
statusMessage: statusMessage, onPressed: logout,
objects: detectedObjects, child: const Icon(Icons.arrow_back),
), ),
),
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(
Positioned( bottom: 30,
bottom: 30, left: MediaQuery.of(context).size.width / 2 - 35,
left: MediaQuery.of(context).size.width / 2 - 35, child: Semantics(
child: Semantics( label: isRecording ? "Stop rekam" : "Mulai rekam",
label: isRecording ? "Stop rekam" : "Mulai rekam", button: true,
button: true, child: GestureDetector(
child: GestureDetector( onTap: () {
onTap: () { if (isRecording) {
if (isRecording) { stopRecording();
stopRecording(); } else {
} else { startRecording();
startRecording(); }
} },
}, child: Container(
child: Container( width: 70,
width: 70, height: 70,
height: 70, decoration: BoxDecoration(
decoration: BoxDecoration( shape: BoxShape.circle,
shape: BoxShape.circle, color: isRecording ? Colors.red : Colors.white,
color: isRecording ? Colors.red : Colors.white, border: Border.all(color: Colors.grey, width: 4),
border: Border.all(color: Colors.grey, width: 4), ),
), child: Icon(
child: Icon( isRecording ? Icons.stop : Icons.circle,
isRecording ? Icons.stop : Icons.circle, color: isRecording ? Colors.white : Colors.red,
color: isRecording ? Colors.white : Colors.red, size: 30,
size: 30, ),
), ),
), ),
), ),
), ),
), ],
], ),
), ),
); );
} }

View File

@ -1,42 +1,105 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:smart_vision_assist/screens/camera_screen.dart';
import '../widgets/custom_button.dart'; import '../widgets/custom_button.dart';
import '../widgets/custom_textfield.dart'; import '../widgets/custom_textfield.dart';
import '../services/tts_service.dart'; import '../services/tts_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../models/login_request.dart';
import '../utils/colors.dart'; import '../utils/colors.dart';
import 'home_screen.dart';
import 'signup_screen.dart'; import 'signup_screen.dart';
import 'forgot_password_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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController emailController = TextEditingController(); final TextEditingController emailController = TextEditingController();
final TextEditingController passwordController = TextEditingController(); final TextEditingController passwordController = TextEditingController();
final TTSService tts = TTSService(); final TTSService tts = TTSService();
final AuthService auth = AuthService(); final AuthService auth = AuthService();
LoginScreen({super.key}); bool isLoading = false;
// 🔐 LOGIN @override
void handleLogin(BuildContext context) { void initState() {
bool success = auth.login( super.initState();
emailController.text, WidgetsBinding.instance.addPostFrameCallback((_) {
passwordController.text, tts.speak("Silakan login");
});
}
@override
void dispose() {
emailController.dispose();
passwordController.dispose();
super.dispose();
}
Future<void> 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) { if (!context.mounted) {
tts.speak("Login berhasil"); return;
Navigator.push( }
setState(() {
isLoading = false;
});
if (response.isSuccess && response.token != null) {
_showMessage(
response.message.isNotEmpty ? response.message : "Login berhasil",
);
Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (_) => HomeScreen()), MaterialPageRoute(builder: (_) => const CameraScreen()),
); );
} else { } 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) { void handleForgotPassword(BuildContext context) {
tts.speak("Menu lupa password"); tts.speak("Menu lupa password");
Navigator.push( Navigator.push(
@ -45,94 +108,96 @@ class LoginScreen extends StatelessWidget {
); );
} }
// 🆕 SIGN UP
void handleSignUp(BuildContext context) { void handleSignUp(BuildContext context) {
tts.speak("Menu pendaftaran akun"); tts.speak("Menu pendaftaran akun");
Navigator.push( Navigator.push(
context, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
tts.speak("Silakan login");
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(20), child: Padding(
child: Column( padding: const EdgeInsets.all(20),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
// 🔷 TITLE children: [
Text( Text(
"Object Detector Assist", "Object Detector Assist",
style: TextStyle( textAlign: TextAlign.center,
color: AppColors.text, style: TextStyle(
fontSize: 24, color: AppColors.text,
fontWeight: FontWeight.bold, 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),
), ),
), ),
),
// 🔹 SIGN UP const SizedBox(height: 40),
Semantics(
button: true, CustomTextField(
label: "Daftar akun baru", label: "Email",
child: TextButton( controller: emailController,
onPressed: () => handleSignUp(context), keyboardType: TextInputType.emailAddress,
child: Text( ),
"Sign Up",
style: TextStyle(color: Colors.yellow), 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),
),
),
),
],
),
), ),
), ),
); );

View File

@ -5,7 +5,7 @@ class ApiConfig {
static const bool useAndroidEmulator = false; static const bool useAndroidEmulator = false;
// Ganti IP ini dengan IP laptop/PC Anda saat menggunakan HP fisik (ADB). // 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 { static String get baseUrl {
if (Platform.isAndroid && useAndroidEmulator) { if (Platform.isAndroid && useAndroidEmulator) {

View File

@ -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 { class AuthService {
bool login(String email, String password) { static const String _tokenKey = "auth_token";
// dummy login
return email == "admin@gmail.com" && password == "1234"; Future<LoginResponse> 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<String, dynamic> jsonBody;
try {
jsonBody = jsonDecode(response.body) as Map<String, dynamic>;
} 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",
);
}
} }
}
Future<void> _saveToken(String token) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/services.dart';
class VibrationService {
static const MethodChannel _channel =
MethodChannel("smart_vision_assist/vibration");
Future<void> vibrateFor(Duration duration) async {
try {
final bool? didVibrate = await _channel.invokeMethod<bool>(
"vibrate",
{"milliseconds": duration.inMilliseconds},
);
if (didVibrate == true) {
return;
}
} catch (_) {
// Fallback untuk perangkat/platform yang tidak mendukung channel native.
}
await HapticFeedback.vibrate();
}
Future<void> stop() async {
try {
await _channel.invokeMethod<void>("stop");
} catch (_) {
// Tidak perlu aksi tambahan jika platform tidak mendukung stop vibration.
}
}
}