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,11 +127,10 @@ 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(); }
}
void markAllRead() { void markAllRead() {
_notifications = _notifications _notifications = _notifications

View File

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

View File

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

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

View File

@ -261,13 +261,13 @@ void main() {
testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async { testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async {
await tester.pumpWidget(makeTestable(const _StubLoginScreen())); await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
final editableText = tester.widget<EditableText>( final editableText = tester.widget<EditableText>(
find.descendant( find.descendant(
of: find.byKey(const Key('password_field')), of: find.byKey(const Key('password_field')),
matching: find.byType(EditableText), matching: find.byType(EditableText),
), ),
); );
expect(editableText.obscureText, isTrue); expect(editableText.obscureText, isTrue);
}); });
testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async { testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async {
@ -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 {
@ -313,9 +313,10 @@ void main() {
await tester.enterText(find.byKey(const Key('password_field')), 'password123'); await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button'))); await tester.tap(find.byKey(const Key('login_button')));
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 {
await tester.pumpWidget(makeTestable(const _StubLoginScreen())); await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
@ -324,9 +325,10 @@ void main() {
await tester.enterText(find.byKey(const Key('password_field')), 'password123'); await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button'))); await tester.tap(find.byKey(const Key('login_button')));
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 {
await tester.pumpWidget(makeTestable(const _StubLoginScreen())); await tester.pumpWidget(makeTestable(const _StubLoginScreen()));

View File

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

View File

@ -103,15 +103,16 @@ 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),
], ),
), ],
),
body: widget.isLoading body: widget.isLoading
? const Center( ? const Center(
child: CircularProgressIndicator(key: Key('loading_indicator'))) 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.tap(find.byKey(const Key('sos_button')));
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',
(tester) async { (tester) async {