feat: finalize AI analyzer, sync endpoints, and stabilize builds

This commit is contained in:
5803024019 2026-05-19 12:27:08 +07:00
parent f697ef16cd
commit 23d0cf6f66
23 changed files with 375 additions and 142 deletions

View File

@ -114,6 +114,16 @@ public class UserController {
"SOS dikirim! Guardian sudah diberitahu."));
}
@GetMapping("/sos-events")
public ResponseEntity<ApiResponse<Page<SosEventResponse>>> getSosEvents(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.getSosEvents(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)),
"Riwayat SOS"));
}
@GetMapping("/activity-logs")
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
@RequestParam(defaultValue = "0") int page,

View File

@ -183,6 +183,10 @@ paths:
post:
responses:
"200": { description: SOS triggered }
/user/sos-events:
get:
responses:
"200": { description: User SOS history }
/user/activity-logs:
get:
responses:

View File

@ -1,11 +1,23 @@
package com.walkguide;
import com.walkguide.config.DataSeeder;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test",
"spring.datasource.username=test",
"spring.datasource.password=test",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none",
"jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
})
class DemoApplicationTests {
@MockBean
private DataSeeder dataSeeder;
@Test
void contextLoads() {
}

View File

@ -281,6 +281,26 @@ class UserControllerTest {
}
}
@Test
@DisplayName("GET /api/v1/user/sos-events - harus return paginated riwayat SOS user")
void getSosEvents_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Page<SosEventResponse> page = new PageImpl<>(List.of());
when(sosService.getSosEvents(eq(1L), any(PageRequest.class))).thenReturn(page);
mockMvc.perform(get("/api/v1/user/sos-events")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Riwayat SOS"));
verify(sosService).getSosEvents(eq(1L), any(PageRequest.class));
}
}
// ===== ACTIVITY LOGS =====
@Test

View File

@ -13,6 +13,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -24,7 +25,7 @@ android {
applicationId = "com.example.walkguide_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@ -42,3 +43,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@ -15,6 +15,42 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
if (project.name == "agora_rtc_engine" || project.name == "iris_method_channel") {
tasks.configureEach {
if (name.startsWith("configureCMake") || name.startsWith("buildCMake")) {
doFirst {
val cmakeFile = listOf(
project.file("src/main/cpp/CMakeLists.txt"),
project.file("../src/CMakeLists.txt"),
).firstOrNull { it.exists() }
if (cmakeFile != null) {
val text = cmakeFile.readText()
if (!text.contains("c++_shared")) {
val patchedText =
if (text.contains("target_link_libraries")) {
text.replace(
" EGL\n )",
" EGL\n c++_shared\n )",
)
} else {
text + """
target_link_libraries(${'$'}{LIBRARY_NAME}
PRIVATE
c++_shared
)
"""
}
cmakeFile.writeText(patchedText)
}
}
}
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
kotlin.incremental=false

View File

@ -0,0 +1 @@
concurrency: 1

View File

@ -1,5 +1,23 @@
enum ObstacleDirection { left, center, right }
class BoundingBox {
final double left;
final double top;
final double right;
final double bottom;
const BoundingBox({
required this.left,
required this.top,
required this.right,
required this.bottom,
});
double get width => right - left;
double get height => bottom - top;
double get centerX => left + width / 2;
}
class DetectionResult {
final String label;
final double confidence;
@ -26,12 +44,73 @@ class DetectionResult {
}
class ObstacleAnalyzer {
DetectionResult analyzeFallback({String label = 'person', double confidence = 0.86}) {
static const double frameWidth = 640.0;
static const double frameHeight = 480.0;
ObstacleDirection analyzeDirection(BoundingBox box) {
final cx = box.centerX;
if (cx < frameWidth * 0.33) return ObstacleDirection.left;
if (cx > frameWidth * 0.67) return ObstacleDirection.right;
return ObstacleDirection.center;
}
String estimateDistance(BoundingBox box) {
final ratio = box.height / frameHeight;
if (ratio > 0.60) return 'Very Close (< 1m)';
if (ratio > 0.35) return 'Close (1-2m)';
if (ratio > 0.15) return 'Medium (2-4m)';
return 'Far (> 4m)';
}
String buildTtsMessage(DetectionResult result) {
final directionLabel = switch (result.direction) {
ObstacleDirection.left => 'kiri',
ObstacleDirection.center => 'depan',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, ${result.label} di $directionLabel. '
'Jarak ${result.estimatedDistance}.';
}
DetectionResult? prioritize(List<DetectionResult> detections) {
if (detections.isEmpty) return null;
const order = [
'Very Close (< 1m)',
'Very Close',
'Close (1-2m)',
'Close',
'Medium (2-4m)',
'Medium',
'Far (> 4m)',
'Far',
];
final sorted = List<DetectionResult>.of(detections);
sorted.sort((a, b) {
final ai = order.indexOf(a.estimatedDistance);
final bi = order.indexOf(b.estimatedDistance);
final aRank = ai == -1 ? order.length : ai;
final bRank = bi == -1 ? order.length : bi;
return aRank.compareTo(bRank);
});
return sorted.first;
}
List<DetectionResult> filterByConfidence(
List<DetectionResult> detections,
double threshold,
) {
return detections.where((d) => d.confidence >= threshold).toList();
}
DetectionResult analyzeFallback({
String label = 'person',
double confidence = 0.86,
}) {
return DetectionResult(
label: label,
confidence: confidence,
direction: ObstacleDirection.center,
estimatedDistance: 'Close',
estimatedDistance: 'Close (1-2m)',
);
}
}

View File

@ -61,6 +61,7 @@ class AppConstants {
await prefs.setString(_selectedYoloModelKey, path);
}
// Agora - ganti dengan App ID dari agora.io
static const String agoraAppId = 'YOUR_AGORA_APP_ID';
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
static const String agoraAppId =
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
}

View File

@ -20,7 +20,7 @@ class CallService {
}
Future<int?> getPairedReceiverId() async {
final res = await _apiClient.dio.get('/pairing/status');
final res = await _apiClient.dio.get('/shared/pairing/status');
final data = res.data['data'];
if (data is! Map<String, dynamic>) return null;
final id = data['pairedWithId'];
@ -72,6 +72,10 @@ class CallService {
int uid = 0,
}) async {
try {
if (AppConstants.agoraAppId.isEmpty) {
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
return false;
}
_engine ??= createAgoraRtcEngine();
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
await _engine!.enableAudio();

View File

@ -947,10 +947,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mgrs_dart:
dependency: transitive
description:
@ -1264,13 +1264,13 @@ packages:
source: hosted
version: "1.2.2"
record_linux:
dependency: transitive
dependency: "direct overridden"
description:
name: record_linux
sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3"
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "1.3.0"
record_platform_interface:
dependency: transitive
description:
@ -1592,26 +1592,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.2"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.11"
version: "0.6.12"
tflite_flutter:
dependency: "direct main"
description:

View File

@ -91,6 +91,9 @@ dev_dependencies:
mockito: ^5.4.4
bloc_test: ^9.1.7
dependency_overrides:
record_linux: ^1.3.0
flutter:
uses-material-design: true
assets:

View File

@ -127,11 +127,10 @@ class _AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 200));
_sosSent = true;
notifyListeners();
}
void sendSos() {
_sosSent = true;
notifyListeners();
}
void markAllRead() {
_notifications = _notifications

View File

@ -60,14 +60,11 @@ class _AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> startWalkGuide() async {
_walkGuideStatus = _WalkGuideStatus.active;
notifyListeners();
// Simulasi obstacle terdeteksi setelah 300ms
await Future.delayed(const Duration(milliseconds: 300));
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
notifyListeners();
}
Future<void> startWalkGuide() async {
_walkGuideStatus = _WalkGuideStatus.active;
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
notifyListeners();
}
void stopWalkGuide() {
_walkGuideStatus = _WalkGuideStatus.idle;
@ -80,11 +77,10 @@ class _AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 150));
_sosStatus = _SosStatus.triggered;
notifyListeners();
}
Future<void> sendSos() async {
_sosStatus = _SosStatus.triggered;
notifyListeners();
}
void goBack() {
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {

View File

@ -101,11 +101,10 @@ class _AppState extends ChangeNotifier {
}
}
Future<void> markAllAsRead() async {
await Future.delayed(const Duration(milliseconds: 150));
for (final n in _notifications) {
n.isRead = true;
}
Future<void> markAllAsRead() async {
for (final n in _notifications) {
n.isRead = true;
}
notifyListeners();
}
}
@ -221,39 +220,42 @@ class _DashboardScreen extends StatelessWidget {
Widget build(BuildContext context) {
final unread = state.unreadCount;
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
Stack(
alignment: Alignment.topRight,
children: [
IconButton(
key: const Key('notifIconButton'),
icon: const Icon(Icons.notifications),
tooltip: 'Notifikasi',
onPressed: state.openNotifications,
),
if (unread > 0)
Positioned(
right: 8,
top: 8,
child: Container(
key: const Key('dashboardBadge'),
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'$unread',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
),
],
),
],
),
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(
key: const Key('notifIconButton'),
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
if (unread > 0)
Positioned(
right: -4,
top: -4,
child: IgnorePointer(
child: Container(
key: const Key('dashboardBadge'),
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'$unread',
style: const TextStyle(
color: Colors.white, fontSize: 10),
),
),
),
),
],
),
tooltip: 'Notifikasi',
onPressed: state.openNotifications,
),
],
),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Selamat datang di Dashboard'),

View File

@ -47,7 +47,19 @@ abstract class AuthRepository {
// File mock di-generate via: flutter pub run build_runner build
// Untuk demo tanpa build_runner, kita buat manual mock di bawah
class MockAuthRepository extends Mock implements AuthRepository {}
class MockAuthRepository extends Mock implements AuthRepository {
@override
Future<Either<Failure, UserEntity>> login(String? email, String? password) =>
super.noSuchMethod(
Invocation.method(#login, [email, password]),
returnValue: Future<Either<Failure, UserEntity>>.value(
const Left(AuthFailure('Repository belum di-stub')),
),
returnValueForMissingStub: Future<Either<Failure, UserEntity>>.value(
const Left(AuthFailure('Repository belum di-stub')),
),
) as Future<Either<Failure, UserEntity>>;
}
// ---------- Use case ----------

View File

@ -98,19 +98,20 @@ class ObstacleAnalyzer {
};
/// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far).
DetectionResult? prioritize(List<DetectionResult> detections) {
if (detections.isEmpty) return null;
const order = [
'Very Close (< 1m)',
'Close (1-2m)',
'Medium (2-4m)',
'Far (> 4m)',
];
detections.sort((a, b) => order
.indexOf(a.estimatedDistance)
.compareTo(order.indexOf(b.estimatedDistance)));
return detections.first;
}
DetectionResult? prioritize(List<DetectionResult> detections) {
if (detections.isEmpty) return null;
const order = [
'Very Close (< 1m)',
'Close (1-2m)',
'Medium (2-4m)',
'Far (> 4m)',
];
final sorted = List<DetectionResult>.of(detections);
sorted.sort((a, b) => order
.indexOf(a.estimatedDistance)
.compareTo(order.indexOf(b.estimatedDistance)));
return sorted.first;
}
/// Filter deteksi berdasarkan confidence threshold.
List<DetectionResult> filterByConfidence(

View File

@ -48,7 +48,33 @@ abstract class RegisterRepository {
});
}
class MockRegisterRepository extends Mock implements RegisterRepository {}
class MockRegisterRepository extends Mock implements RegisterRepository {
@override
Future<Either<Failure, UserEntity>> register({
String? email,
String? password,
String? displayName,
String? role,
}) =>
super.noSuchMethod(
Invocation.method(
#register,
const [],
{
#email: email,
#password: password,
#displayName: displayName,
#role: role,
},
),
returnValue: Future<Either<Failure, UserEntity>>.value(
const Left(ServerFailure('Repository belum di-stub')),
),
returnValueForMissingStub: Future<Either<Failure, UserEntity>>.value(
const Left(ServerFailure('Repository belum di-stub')),
),
) as Future<Either<Failure, UserEntity>>;
}
// ---------- Use case ----------

View File

@ -261,13 +261,13 @@ void main() {
testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async {
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
final editableText = tester.widget<EditableText>(
find.descendant(
of: find.byKey(const Key('password_field')),
matching: find.byType(EditableText),
),
);
expect(editableText.obscureText, isTrue);
final editableText = tester.widget<EditableText>(
find.descendant(
of: find.byKey(const Key('password_field')),
matching: find.byType(EditableText),
),
);
expect(editableText.obscureText, isTrue);
});
testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async {
@ -282,7 +282,7 @@ void main() {
matching: find.byType(EditableText),
),
);
expect(editableText.obscureText, isTrue);
expect(editableText.obscureText, isFalse);
});
testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async {
@ -313,9 +313,10 @@ void main() {
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button')));
await tester.pump(); // Trigger rebuild
expect(find.byKey(const Key('loading_indicator')), findsOneWidget);
});
expect(find.byKey(const Key('loading_indicator')), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('menyembunyikan tombol login saat loading', (tester) async {
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
@ -324,9 +325,10 @@ void main() {
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button')));
await tester.pump();
expect(find.byKey(const Key('login_button')), findsNothing);
});
expect(find.byKey(const Key('login_button')), findsNothing);
await tester.pumpAndSettle();
});
testWidgets('loading selesai setelah async operation', (tester) async {
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));

View File

@ -363,11 +363,16 @@ class _StubManualScreenState extends State<_StubManualScreen> {
}
}
Widget makeTestable(Widget child) => MaterialApp(home: child);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Widget makeTestable(Widget child) => MaterialApp(home: child);
Finder _commandScrollable() => find.descendant(
of: find.byKey(const Key('command_list')),
matching: find.byType(Scrollable),
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('ManualScreen Widget Tests', () {
@ -418,12 +423,13 @@ void main() {
group('Konten perintah suara', () {
testWidgets('menampilkan tile untuk perintah Open Walkguide',
(tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.byKey(const Key('cmd_tile_openWalkguide')),
200,
);
expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget);
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.byKey(const Key('cmd_tile_openWalkguide')),
200,
scrollable: _commandScrollable(),
);
expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget);
});
testWidgets('menampilkan phrase perintah dalam tanda kutip',
@ -449,25 +455,36 @@ void main() {
expect(find.text('"Call Guardian"'), findsOneWidget);
});
testWidgets('menampilkan perintah Send SOS', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(find.text('"Send SOS"'), 200);
expect(find.text('"Send SOS"'), findsOneWidget);
});
testWidgets('menampilkan perintah Where Am I', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(find.text('"Where Am I"'), 200);
expect(find.text('"Where Am I"'), findsOneWidget);
});
testWidgets('menampilkan perintah Send SOS', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.text('"Send SOS"'),
200,
scrollable: _commandScrollable(),
);
expect(find.text('"Send SOS"'), findsOneWidget);
});
testWidgets('menampilkan perintah Where Am I', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.text('"Where Am I"'),
200,
scrollable: _commandScrollable(),
);
expect(find.text('"Where Am I"'), findsOneWidget);
});
testWidgets('menampilkan kategori Darurat untuk Send SOS',
(tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.byKey(const Key('cmd_category_sendSos')), 200);
expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget);
});
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(
find.byKey(const Key('cmd_category_sendSos')),
200,
scrollable: _commandScrollable(),
);
expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget);
});
});
// Dialog info

View File

@ -103,15 +103,16 @@ class _StubNotificationScreenState extends State<_StubNotificationScreen> {
],
],
),
actions: [
if (_items.any((e) => !e.isRead))
TextButton(
key: const Key('mark_all_read_button'),
onPressed: _markingAll ? null : _markAllRead,
child: const Text('Tandai Semua Dibaca'),
),
],
),
actions: [
if (_items.any((e) => !e.isRead))
IconButton(
key: const Key('mark_all_read_button'),
onPressed: _markingAll ? null : _markAllRead,
tooltip: 'Tandai Semua Dibaca',
icon: const Icon(Icons.done_all),
),
],
),
body: widget.isLoading
? const Center(
child: CircularProgressIndicator(key: Key('loading_indicator')))

View File

@ -431,9 +431,10 @@ void main() {
await tester.tap(find.byKey(const Key('sos_button')));
await tester.pump();
expect(find.byKey(const Key('sending_indicator')), findsOneWidget);
});
expect(find.byKey(const Key('sending_indicator')), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('setelah SOS terkirim, tampil success banner',
(tester) async {