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.")); "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") @GetMapping("/activity-logs")
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs( public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,

View File

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

View File

@ -1,11 +1,23 @@
package com.walkguide; package com.walkguide;
import com.walkguide.config.DataSeeder;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; 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 { class DemoApplicationTests {
@MockBean
private DataSeeder dataSeeder;
@Test @Test
void contextLoads() { 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 ===== // ===== ACTIVITY LOGS =====
@Test @Test

View File

@ -13,6 +13,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@ -24,7 +25,7 @@ android {
applicationId = "com.example.walkguide_app" applicationId = "com.example.walkguide_app"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 26
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@ -42,3 +43,7 @@ android {
flutter { flutter {
source = "../.." 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) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) 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 { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=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 } 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 { class DetectionResult {
final String label; final String label;
final double confidence; final double confidence;
@ -26,12 +44,73 @@ class DetectionResult {
} }
class ObstacleAnalyzer { 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( return DetectionResult(
label: label, label: label,
confidence: confidence, confidence: confidence,
direction: ObstacleDirection.center, direction: ObstacleDirection.center,
estimatedDistance: 'Close', estimatedDistance: 'Close (1-2m)',
); );
} }
} }

View File

@ -61,6 +61,7 @@ class AppConstants {
await prefs.setString(_selectedYoloModelKey, path); await prefs.setString(_selectedYoloModelKey, path);
} }
// Agora - ganti dengan App ID dari agora.io // Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
static const String agoraAppId = 'YOUR_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 { 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']; final data = res.data['data'];
if (data is! Map<String, dynamic>) return null; if (data is! Map<String, dynamic>) return null;
final id = data['pairedWithId']; final id = data['pairedWithId'];
@ -72,6 +72,10 @@ class CallService {
int uid = 0, int uid = 0,
}) async { }) async {
try { try {
if (AppConstants.agoraAppId.isEmpty) {
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
return false;
}
_engine ??= createAgoraRtcEngine(); _engine ??= createAgoraRtcEngine();
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId)); await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
await _engine!.enableAudio(); await _engine!.enableAudio();

View File

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

View File

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

View File

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

View File

@ -62,9 +62,6 @@ class _AppState extends ChangeNotifier {
Future<void> startWalkGuide() async { Future<void> startWalkGuide() async {
_walkGuideStatus = _WalkGuideStatus.active; _walkGuideStatus = _WalkGuideStatus.active;
notifyListeners();
// Simulasi obstacle terdeteksi setelah 300ms
await Future.delayed(const Duration(milliseconds: 300));
_detectedObstacles = ['person (87%)', 'motorcycle (72%)']; _detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
notifyListeners(); notifyListeners();
} }
@ -81,7 +78,6 @@ class _AppState extends ChangeNotifier {
} }
Future<void> sendSos() async { Future<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 150));
_sosStatus = _SosStatus.triggered; _sosStatus = _SosStatus.triggered;
notifyListeners(); notifyListeners();
} }

View File

@ -102,7 +102,6 @@ class _AppState extends ChangeNotifier {
} }
Future<void> markAllAsRead() async { Future<void> markAllAsRead() async {
await Future.delayed(const Duration(milliseconds: 150));
for (final n in _notifications) { for (final n in _notifications) {
n.isRead = true; n.isRead = true;
} }
@ -224,19 +223,17 @@ class _DashboardScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('Dashboard'), title: const Text('Dashboard'),
actions: [ actions: [
Stack(
alignment: Alignment.topRight,
children: [
IconButton( IconButton(
key: const Key('notifIconButton'), key: const Key('notifIconButton'),
icon: const Icon(Icons.notifications), icon: Stack(
tooltip: 'Notifikasi', clipBehavior: Clip.none,
onPressed: state.openNotifications, children: [
), const Icon(Icons.notifications),
if (unread > 0) if (unread > 0)
Positioned( Positioned(
right: 8, right: -4,
top: 8, top: -4,
child: IgnorePointer(
child: Container( child: Container(
key: const Key('dashboardBadge'), key: const Key('dashboardBadge'),
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@ -246,12 +243,17 @@ class _DashboardScreen extends StatelessWidget {
), ),
child: Text( child: Text(
'$unread', '$unread',
style: const TextStyle(color: Colors.white, fontSize: 10), style: const TextStyle(
color: Colors.white, fontSize: 10),
),
), ),
), ),
), ),
], ],
), ),
tooltip: 'Notifikasi',
onPressed: state.openNotifications,
),
], ],
), ),
body: Center( body: Center(

View File

@ -47,7 +47,19 @@ abstract class AuthRepository {
// File mock di-generate via: flutter pub run build_runner build // File mock di-generate via: flutter pub run build_runner build
// Untuk demo tanpa build_runner, kita buat manual mock di bawah // 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 ---------- // ---------- Use case ----------

View File

@ -106,10 +106,11 @@ class ObstacleAnalyzer {
'Medium (2-4m)', 'Medium (2-4m)',
'Far (> 4m)', 'Far (> 4m)',
]; ];
detections.sort((a, b) => order final sorted = List<DetectionResult>.of(detections);
sorted.sort((a, b) => order
.indexOf(a.estimatedDistance) .indexOf(a.estimatedDistance)
.compareTo(order.indexOf(b.estimatedDistance))); .compareTo(order.indexOf(b.estimatedDistance)));
return detections.first; return sorted.first;
} }
/// Filter deteksi berdasarkan confidence threshold. /// Filter deteksi berdasarkan confidence threshold.

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 ---------- // ---------- Use case ----------

View File

@ -282,7 +282,7 @@ void main() {
matching: find.byType(EditableText), matching: find.byType(EditableText),
), ),
); );
expect(editableText.obscureText, isTrue); expect(editableText.obscureText, isFalse);
}); });
testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async { testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async {
@ -315,6 +315,7 @@ void main() {
await tester.pump(); // Trigger rebuild 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 { testWidgets('menyembunyikan tombol login saat loading', (tester) async {
@ -326,6 +327,7 @@ void main() {
await tester.pump(); 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 { testWidgets('loading selesai setelah async operation', (tester) async {

View File

@ -365,6 +365,11 @@ class _StubManualScreenState extends State<_StubManualScreen> {
Widget makeTestable(Widget child) => MaterialApp(home: child); Widget makeTestable(Widget child) => MaterialApp(home: child);
Finder _commandScrollable() => find.descendant(
of: find.byKey(const Key('command_list')),
matching: find.byType(Scrollable),
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -422,6 +427,7 @@ void main() {
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.byKey(const Key('cmd_tile_openWalkguide')), find.byKey(const Key('cmd_tile_openWalkguide')),
200, 200,
scrollable: _commandScrollable(),
); );
expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget); expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget);
}); });
@ -451,13 +457,21 @@ void main() {
testWidgets('menampilkan perintah Send SOS', (tester) async { testWidgets('menampilkan perintah Send SOS', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen())); await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(find.text('"Send SOS"'), 200); await tester.scrollUntilVisible(
find.text('"Send SOS"'),
200,
scrollable: _commandScrollable(),
);
expect(find.text('"Send SOS"'), findsOneWidget); expect(find.text('"Send SOS"'), findsOneWidget);
}); });
testWidgets('menampilkan perintah Where Am I', (tester) async { testWidgets('menampilkan perintah Where Am I', (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen())); await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible(find.text('"Where Am I"'), 200); await tester.scrollUntilVisible(
find.text('"Where Am I"'),
200,
scrollable: _commandScrollable(),
);
expect(find.text('"Where Am I"'), findsOneWidget); expect(find.text('"Where Am I"'), findsOneWidget);
}); });
@ -465,7 +479,10 @@ void main() {
(tester) async { (tester) async {
await tester.pumpWidget(makeTestable(const _StubManualScreen())); await tester.pumpWidget(makeTestable(const _StubManualScreen()));
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.byKey(const Key('cmd_category_sendSos')), 200); find.byKey(const Key('cmd_category_sendSos')),
200,
scrollable: _commandScrollable(),
);
expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget); expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget);
}); });
}); });

View File

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

View File

@ -433,6 +433,7 @@ void main() {
await tester.pump(); 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', testWidgets('setelah SOS terkirim, tampil success banner',