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

This commit is contained in:
2026-05-19 12:27:08 +07:00
parent f697ef16cd
commit 23d0cf6f66
23 changed files with 375 additions and 142 deletions
@@ -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
@@ -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') {
@@ -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'),
@@ -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 ----------
@@ -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(
@@ -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 ----------
@@ -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()));
@@ -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 ───────────────────────────────────────────────────────
@@ -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')))
@@ -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 {