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.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"

View File

@ -1,5 +1,71 @@
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.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: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<CameraScreen> {
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<CameraScreen> {
Size? detectionFrameSize;
Timer? _detectionTimer;
Timer? _vibrationTimer;
bool _isVibrating = false;
bool _isLoggingOut = false;
String _lastSpokenMessage = "";
DateTime? _lastSpokenAt;
@ -101,7 +105,8 @@ class _CameraScreenState extends State<CameraScreen> {
_detectionTimer = null;
_vibrationTimer?.cancel();
_vibrationTimer = null;
_isVibrating = false;
await _vibrationService.stop();
if (!mounted) return;
setState(() {
@ -114,6 +119,43 @@ class _CameraScreenState extends State<CameraScreen> {
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 {
final CameraController? cameraController = controller;
if (cameraController == null ||
@ -196,7 +238,7 @@ class _CameraScreenState extends State<CameraScreen> {
return;
}
_vibrateForTwoSeconds();
await _vibrateForSevenSeconds();
final Map<String, int> objectCounts = {};
for (final YoloDetection object in response.objects) {
@ -212,29 +254,16 @@ class _CameraScreenState extends State<CameraScreen> {
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<void> _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<CameraScreen> {
void dispose() {
_detectionTimer?.cancel();
_vibrationTimer?.cancel();
unawaited(_vibrationService.stop());
controller?.dispose();
super.dispose();
}
@ -291,94 +321,108 @@ class _CameraScreenState extends State<CameraScreen> {
@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,
),
),
),
),
),
),
],
],
),
),
);
}

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<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) {
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),
),
),
),
],
),
),
),
);

View File

@ -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) {

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 {
bool login(String email, String password) {
// dummy login
return email == "admin@gmail.com" && password == "1234";
static const String _tokenKey = "auth_token";
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.
}
}
}