Compare commits

...

5 Commits

53 changed files with 3194 additions and 3860 deletions

11
.gitignore vendored
View File

@ -46,3 +46,14 @@ walkguide-backend/demo/hs_err_pid*.log
# Android SDK path (generated by Android Studio) # Android SDK path (generated by Android Studio)
walkguide-mobile/walkguide_app/android/local.properties walkguide-mobile/walkguide_app/android/local.properties
# Local Python/YOLO export artifacts - do not commit
walkguide-mobile/walkguide_app/.venv*/
walkguide-mobile/walkguide_app/yolov8n.onnx
walkguide-mobile/walkguide_app/yolov8n.pt
walkguide-mobile/walkguide_app/yolov8n_saved_model/
walkguide-mobile/walkguide_app/calibration_image_sample_data_*.npy
walkguide-mobile/walkguide_app/devtools_options.yaml
walkguide-mobile/walkguide_app/android/app/src/main/java/dev/flutter/plugins/
walkguide-mobile/walkguide_app/android/app/src/main/java/io/flutter/plugins/
new_file

View File

@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.CallNotifyRequest; import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest; import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse; import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper; import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService; import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService; import com.walkguide.service.CallNotificationService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -17,23 +14,13 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
/**
* Controller untuk VoIP call via Agora RTC.
*
* FLOW:
* 1. Caller POST /shared/call/token dapat Agora token + channelName
* 2. Caller join Agora channel (di Flutter)
* 3. Caller POST /shared/call/notify FCM "Incoming Call" dikirim ke receiver
* 4. Receiver terima FCM join channel yang sama
* 5. Audio call tersambung via Agora
*
* Route: /api/v1/shared/call
* Auth: Authenticated (Guardian ATAU User)
*/
@RestController @RestController
@RequestMapping("/api/v1/shared/call") @RequestMapping("/api/v1/shared/call")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -43,17 +30,10 @@ import java.util.Map;
public class CallController { public class CallController {
private final AgoraTokenService agoraTokenService; private final AgoraTokenService agoraTokenService;
private final FcmService fcmService; private final CallNotificationService callNotificationService;
private final UserRepository userRepository;
/**
* Generate Agora RTC token untuk call session.
* Dipanggil oleh CALLER sebelum mulai call.
*
* POST /api/v1/shared/call/token
*/
@PostMapping("/token") @PostMapping("/token")
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel") @Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken( public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) { @Valid @RequestBody CallTokenRequest req) {
@ -66,84 +46,24 @@ public class CallController {
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate")); return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
} }
/**
* Kirim FCM "Incoming Call" ke receiver.
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
*
* POST /api/v1/shared/call/notify
*/
@PostMapping("/notify") @PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call", @Operation(summary = "Notify receiver of incoming call")
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
public ResponseEntity<ApiResponse<Void>> notifyCall( public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) { @Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId(); Long callerId = SecurityHelper.getCurrentUserId();
String message = callNotificationService.notifyIncomingCall(callerId, req);
// Ambil info caller untuk notifikasi return ResponseEntity.ok(ApiResponse.ok(null, message));
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
// Ambil FCM token receiver
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
return ResponseEntity.ok(ApiResponse.ok(null,
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
}
// Payload FCM untuk incoming call
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
// High priority karena incoming call harus segera muncul
fcmService.sendHighPriority(
receiver.getFcmToken(),
"📞 Panggilan Masuk",
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
} }
/**
* End call notifikasi ke pihak lain bahwa call sudah berakhir.
* Opsional: Flutter bisa handle end call secara lokal juga.
*
* POST /api/v1/shared/call/end
*/
@PostMapping("/end") @PostMapping("/end")
@Operation(summary = "Notify end of call") @Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall( public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) { @RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId(); Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId"); Long otherId = body.get("otherId");
callNotificationService.notifyCallEnded(callerId, otherId);
if (otherId != null) {
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
}
});
}
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended")); return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
} }

View File

@ -51,7 +51,7 @@ public class GuardianController {
Long guardianId = SecurityHelper.getCurrentUserId(); Long guardianId = SecurityHelper.getCurrentUserId();
// Perlu ambil userId dulu delegasikan ke service // Perlu ambil userId dulu delegasikan ke service
return ResponseEntity.ok(ApiResponse.ok( return ResponseEntity.ok(ApiResponse.ok(
locationService.getLocationHistory(guardianId, locationService.getLocationHistoryForGuardian(guardianId,
PageRequest.of(page, size, Sort.by("createdAt").descending())), PageRequest.of(page, size, Sort.by("createdAt").descending())),
"Riwayat lokasi")); "Riwayat lokasi"));
} }

View File

@ -2,9 +2,11 @@ package com.walkguide.controller;
import com.walkguide.dto.ApiResponse; import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*; import com.walkguide.dto.request.*;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.dto.response.PairingStatusResponse; import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.security.SecurityHelper; import com.walkguide.security.SecurityHelper;
import com.walkguide.service.PairingService; import com.walkguide.service.PairingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -16,12 +18,26 @@ public class PairingController {
private final PairingService pairingService; private final PairingService pairingService;
@GetMapping("/code")
public ResponseEntity<ApiResponse<PairingCodeResponse>> code() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code aktif"));
}
@PostMapping("/code/regenerate")
public ResponseEntity<ApiResponse<PairingCodeResponse>> regenerateCode() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code baru dibuat"));
}
@PostMapping("/invite") @PostMapping("/invite")
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite( public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
@RequestBody InviteUserRequest req) { @Valid @RequestBody InviteUserRequest req) {
Long guardianId = SecurityHelper.getCurrentUserId(); Long guardianId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok( return ResponseEntity.ok(ApiResponse.ok(
pairingService.inviteUser(guardianId, req.getUniqueUserId()), pairingService.inviteUser(guardianId, req.resolveSubmittedCode()),
"Undangan dikirim ke user")); "Undangan dikirim ke user"));
} }

View File

@ -5,7 +5,16 @@ import lombok.Data;
@Data @Data
public class InviteUserRequest { public class InviteUserRequest {
@NotBlank(message = "User ID tidak boleh kosong")
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter") @Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
private String uniqueUserId; private String uniqueUserId;
@Size(min = 8, max = 8, message = "Pairing code harus tepat 8 karakter")
private String pairingCode;
public String resolveSubmittedCode() {
if (pairingCode != null && !pairingCode.isBlank()) {
return pairingCode.trim().toUpperCase();
}
return uniqueUserId == null ? null : uniqueUserId.trim().toUpperCase();
}
} }

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class PairingCodeResponse {
private String pairingCode;
private LocalDateTime expiresAt;
private long expiresInSeconds;
}

View File

@ -12,6 +12,8 @@ public class PairingStatusResponse {
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian) private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
private String pairedWithEmail; private String pairedWithEmail;
private String uniqueUserId; // ID user yang di-pair private String uniqueUserId; // ID user yang di-pair
private String pairingCode; // temporary code for new pairing flow
private LocalDateTime pairingCodeExpiresAt;
private LocalDateTime invitedAt; private LocalDateTime invitedAt;
private LocalDateTime respondedAt; private LocalDateTime respondedAt;
} }

View File

@ -29,6 +29,12 @@ public class User {
@Column(name = "unique_user_id", unique = true, length = 12) @Column(name = "unique_user_id", unique = true, length = 12)
private String uniqueUserId; private String uniqueUserId;
@Column(name = "pairing_code", unique = true, length = 8)
private String pairingCode;
@Column(name = "pairing_code_expires_at")
private LocalDateTime pairingCodeExpiresAt;
@Column(name = "display_name", length = 100) @Column(name = "display_name", length = 100)
private String displayName; private String displayName;

View File

@ -9,5 +9,6 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
Optional<User> findByUniqueUserId(String uniqueUserId); Optional<User> findByUniqueUserId(String uniqueUserId);
Optional<User> findByPairingCode(String pairingCode);
boolean existsByEmail(String email); boolean existsByEmail(String email);
} }

View File

@ -0,0 +1,73 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class CallNotificationService {
private final FcmService fcmService;
private final UserRepository userRepository;
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
}
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", callerName,
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
fcmService.sendHighPriority(
receiver.getFcmToken(),
"Panggilan Masuk",
"Panggilan dari " + callerName,
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return "Notifikasi panggilan berhasil dikirim";
}
public void notifyCallEnded(Long callerId, Long otherId) {
if (otherId == null) {
return;
}
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
return;
}
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
});
}
}

View File

@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User; import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType; import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus; import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*; import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster; import com.walkguide.websocket.LocationBroadcaster;
@ -79,6 +80,15 @@ public class LocationService {
.map(this::toResponse); .map(this::toResponse);
} }
public Page<LocationResponse> getLocationHistoryForGuardian(Long guardianId, Pageable pageable) {
Long pairedUserId = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.map(pairing -> pairing.getUser().getId())
.orElseThrow(() -> new PairingException("Guardian belum terhubung dengan User aktif"));
return getLocationHistory(pairedUserId, pageable);
}
// ========== GEOFENCE ========== // ========== GEOFENCE ==========
private void checkGeofence(Long userId, double lat, double lng) { private void checkGeofence(Long userId, double lat, double lng) {

View File

@ -1,6 +1,7 @@
package com.walkguide.service; package com.walkguide.service;
import com.walkguide.dto.response.PairingStatusResponse; import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.entity.*; import com.walkguide.entity.*;
import com.walkguide.enums.*; import com.walkguide.enums.*;
import com.walkguide.exception.PairingException; import com.walkguide.exception.PairingException;
@ -10,7 +11,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -26,8 +29,46 @@ public class PairingService {
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
private final FcmService fcmService; private final FcmService fcmService;
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static final int PAIRING_CODE_LENGTH = 8;
private static final int PAIRING_CODE_TTL_MINUTES = 15;
private static final SecureRandom RANDOM = new SecureRandom();
@Transactional @Transactional
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) { public PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
if (user.getPairingCode() == null
|| user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(now)) {
assignNewPairingCode(user, now);
userRepository.save(user);
}
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingCodeResponse regeneratePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
assignNewPairingCode(user, now);
userRepository.save(user);
activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT,
"User membuat pairing code baru", null);
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING // Guardian tidak boleh punya pairing ACTIVE atau PENDING
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) { if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru."); throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
@ -38,8 +79,7 @@ public class PairingService {
User guardian = userRepository.findById(guardianId) User guardian = userRepository.findById(guardianId)
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan")); .orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
User user = userRepository.findByUniqueUserId(uniqueUserId) User user = resolveUserByPairingCode(submittedCode);
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) { if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar."); throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
@ -55,6 +95,10 @@ public class PairingService {
.build(); .build();
pairing = pairingRelationRepository.save(pairing); pairing = pairingRelationRepository.save(pairing);
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
// Kirim FCM ke user // Kirim FCM ke user
fcmService.sendToToken(user.getFcmToken(), fcmService.sendToToken(user.getFcmToken(),
"Pairing Request", "Pairing Request",
@ -199,6 +243,51 @@ public class PairingService {
.enabled(name != null).build(); .enabled(name != null).build();
} }
private User resolveUserByPairingCode(String submittedCode) {
if (submittedCode == null || submittedCode.isBlank()) {
throw new PairingException("Pairing code tidak boleh kosong.");
}
String code = submittedCode.trim().toUpperCase();
User user = userRepository.findByPairingCode(code)
.orElseThrow(() -> new ResourceNotFoundException(
"Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru."));
if (user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) {
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru.");
}
return user;
}
private void assignNewPairingCode(User user, LocalDateTime now) {
String candidate;
do {
candidate = randomCode();
} while (userRepository.findByPairingCode(candidate).isPresent());
user.setPairingCode(candidate);
user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES));
}
private String randomCode() {
StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH);
for (int i = 0; i < PAIRING_CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) {
long seconds = Math.max(0,
ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt()));
return PairingCodeResponse.builder()
.pairingCode(user.getPairingCode())
.expiresAt(user.getPairingCodeExpiresAt())
.expiresInSeconds(seconds)
.build();
}
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) { private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
String pairedWithName = "GUARDIAN".equals(viewerRole) String pairedWithName = "GUARDIAN".equals(viewerRole)
? user.getDisplayName() : guardian.getDisplayName(); ? user.getDisplayName() : guardian.getDisplayName();
@ -211,6 +300,8 @@ public class PairingService {
.pairedWithName(pairedWithName) .pairedWithName(pairedWithName)
.pairedWithEmail(pairedWithEmail) .pairedWithEmail(pairedWithEmail)
.uniqueUserId(user.getUniqueUserId()) .uniqueUserId(user.getUniqueUserId())
.pairingCode(user.getPairingCode())
.pairingCodeExpiresAt(user.getPairingCodeExpiresAt())
.invitedAt(p.getInvitedAt()) .invitedAt(p.getInvitedAt())
.respondedAt(p.getRespondedAt()) .respondedAt(p.getRespondedAt())
.build(); .build();

View File

@ -6,9 +6,9 @@
spring: spring:
datasource: datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} url: ${DB_URL}
username: ${DB_USERNAME:5803024001} username: ${DB_USERNAME}
password: ${DB_PASSWORD:pw5803024001} password: ${DB_PASSWORD}
jpa: jpa:
show-sql: true show-sql: true
@ -17,8 +17,8 @@ spring:
format_sql: true format_sql: true
jwt: jwt:
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970} secret: ${JWT_SECRET}
expiration: 86400000 expiration: ${JWT_EXPIRATION:86400000}
agora: agora:
app-id: ${AGORA_APP_ID:} app-id: ${AGORA_APP_ID:}

View File

@ -0,0 +1,9 @@
-- V17: Expiring pairing code for lecturer revision.
-- unique_user_id remains a stable account identifier; pairing_code is temporary.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS pairing_code VARCHAR(8) UNIQUE,
ADD COLUMN IF NOT EXISTS pairing_code_expires_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_pairing_code ON users(pairing_code);
CREATE INDEX IF NOT EXISTS idx_users_pairing_code_expires_at ON users(pairing_code_expires_at);

View File

@ -4,7 +4,8 @@ info:
version: 1.0.0 version: 1.0.0
description: Design contract for WalkGuide Flutter and Spring Boot integration. description: Design contract for WalkGuide Flutter and Spring Boot integration.
servers: servers:
- url: http://localhost:8080/api/v1 - url: https://api.walkguide.example/api/v1
description: Production deployment URL placeholder
security: security:
- bearerAuth: [] - bearerAuth: []
components: components:
@ -37,9 +38,16 @@ components:
role: { type: string, enum: [USER, GUARDIAN] } role: { type: string, enum: [USER, GUARDIAN] }
PairingInviteRequest: PairingInviteRequest:
type: object type: object
required: [uniqueUserId] required: [pairingCode]
properties: properties:
uniqueUserId: { type: string, minLength: 12, maxLength: 12 } pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
uniqueUserId: { type: string, minLength: 12, maxLength: 12, deprecated: true }
PairingCodeResponse:
type: object
properties:
pairingCode: { type: string }
expiresAt: { type: string, format: date-time }
expiresInSeconds: { type: integer, format: int64 }
PairingRespondRequest: PairingRespondRequest:
type: object type: object
required: [pairingId, accept] required: [pairingId, accept]
@ -107,6 +115,14 @@ paths:
schema: { $ref: "#/components/schemas/PairingInviteRequest" } schema: { $ref: "#/components/schemas/PairingInviteRequest" }
responses: responses:
"200": { description: Invite sent } "200": { description: Invite sent }
/shared/pairing/code:
get:
responses:
"200": { description: Active expiring pairing code }
/shared/pairing/code/regenerate:
post:
responses:
"200": { description: New expiring pairing code generated }
/shared/pairing/respond: /shared/pairing/respond:
post: post:
requestBody: requestBody:

View File

@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.walkguide.dto.request.CallNotifyRequest; import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest; import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse; import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User; import com.walkguide.security.JwtAuthFilter;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper; import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService; import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService; import com.walkguide.service.CallNotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@ -18,19 +15,22 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import com.walkguide.security.JwtAuthFilter;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@WebMvcTest(CallController.class) @WebMvcTest(CallController.class)
@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DisplayName("CallController Unit Tests") @DisplayName("CallController Unit Tests")
class CallControllerTest { class CallControllerTest {
@MockBean private JwtAuthFilter jwtAuthFilter; @MockBean
private JwtAuthFilter jwtAuthFilter;
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -50,35 +51,10 @@ class CallControllerTest {
private AgoraTokenService agoraTokenService; private AgoraTokenService agoraTokenService;
@MockBean @MockBean
private FcmService fcmService; private CallNotificationService callNotificationService;
@MockBean
private UserRepository userRepository;
private User sampleCaller;
private User sampleReceiver;
@BeforeEach
void setUp() {
sampleCaller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.fcmToken("fcm-caller-token")
.build();
sampleReceiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver Guardian")
.fcmToken("fcm-receiver-token")
.build();
}
// ===== GENERATE TOKEN =====
@Test @Test
@DisplayName("POST /api/v1/shared/call/token - valid request harus return Agora token") @DisplayName("POST /api/v1/shared/call/token - valid request returns Agora token")
void generateToken_validRequest_shouldReturn200() throws Exception { void generateToken_validRequest_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@ -88,7 +64,7 @@ class CallControllerTest {
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder() AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
.token("agora-rtc-token-xyz") .token("agora-rtc-token-xyz")
.channelName("call_1_2_1234567890") .channelName("call_1_2")
.uid(1001) .uid(1001)
.build(); .build();
@ -102,34 +78,24 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate")) .andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz")) .andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890")); .andExpect(jsonPath("$.data.channelName").value("call_1_2"));
verify(agoraTokenService).generateToken(1L, 2L);
} }
} }
@Test @Test
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400") @DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
void generateToken_nullReceiverId_shouldReturn400() throws Exception { void generateToken_nullReceiverId_shouldReturn400() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { CallTokenRequest req = new CallTokenRequest();
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
// CallTokenRequest dengan receiverId null @Valid harus menolak mockMvc.perform(post("/api/v1/shared/call/token")
CallTokenRequest req = new CallTokenRequest(); .with(csrf())
// receiverId tidak di-set (null) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
mockMvc.perform(post("/api/v1/shared/call/token") .andExpect(status().isBadRequest());
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
}
} }
@Test @Test
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 500") @DisplayName("POST /api/v1/shared/call/token - service error returns 500")
void generateToken_serviceThrows_shouldReturn500() throws Exception { void generateToken_serviceThrows_shouldReturn500() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@ -148,21 +114,17 @@ class CallControllerTest {
} }
} }
// ===== NOTIFY CALL =====
@Test @Test
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi") @DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception { void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)))
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller)); .thenReturn("Notifikasi panggilan berhasil dikirim");
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
CallNotifyRequest req = new CallNotifyRequest(); CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L); req.setReceiverId(2L);
req.setChannelName("call_1_2_1234567890"); req.setChannelName("call_1_2");
req.setAgoraToken("agora-token-xyz"); req.setAgoraToken("agora-token-xyz");
req.setReceiverUid(1002); req.setReceiverUid(1002);
@ -174,260 +136,40 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim")); .andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
verify(fcmService).sendHighPriority( verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
eq("fcm-receiver-token"),
eq("📞 Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
} }
} }
@Test @Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM") @DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception { void notifyCall_invalidRequest_shouldReturn400() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { CallNotifyRequest req = new CallNotifyRequest();
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); req.setChannelName("call_1_2");
User receiverNoFcm = User.builder() mockMvc.perform(post("/api/v1/shared/call/notify")
.id(2L) .with(csrf())
.email("receiver@test.com") .contentType(MediaType.APPLICATION_JSON)
.displayName("Receiver") .content(objectMapper.writeValueAsString(req)))
.fcmToken(null) // tidak punya FCM token .andExpect(status().isBadRequest());
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller)); verify(callNotificationService, never()).notifyIncomingCall(any(), any());
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_abc");
req.setAgoraToken("token-xyz");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
// FCM tidak boleh dipanggil karena tidak ada token
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
} }
@Test @Test
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM") @DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception { void endCall_validOtherId_shouldDelegateToService() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User receiverBlankFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(" ") // blank
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverBlankFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller tidak ditemukan harus return 404")
void notifyCall_callerNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L))
.thenThrow(new ResourceNotFoundException("Caller not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak ditemukan harus return 404")
void notifyCall_receiverNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L))
.thenThrow(new ResourceNotFoundException("Receiver not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller displayName null harus pakai email sebagai pengganti")
void notifyCall_callerNoDisplayName_shouldUseEmailAsFallback() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User callerNoName = User.builder()
.id(1L)
.email("noreply@test.com")
.displayName(null) // tidak ada displayName
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(callerNoName));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// Pastikan body notifikasi menggunakan email sebagai fallback
verify(fcmService).sendHighPriority(
anyString(),
anyString(),
contains("noreply@test.com"),
anyMap()
);
}
}
// ===== END CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId valid dan punya FCM token harus kirim notifikasi berakhir")
void endCall_otherHasFcmToken_shouldSendEndNotification() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end") mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf()) .with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body))) .content(objectMapper.writeValueAsString(Map.of("otherId", 2L))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Call ended")); .andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService).sendToToken( verify(callNotificationService).notifyCallEnded(1L, 2L);
eq("fcm-receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId null harus return 200 tanpa kirim FCM")
void endCall_nullOtherId_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Map<String, Long> body = Map.of(); // tidak ada otherId
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - other tidak punya FCM token harus return 200 tanpa FCM")
void endCall_otherNoFcmToken_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User otherNoFcm = User.builder()
.id(2L)
.email("other@test.com")
.fcmToken(null)
.build();
when(userRepository.findById(2L)).thenReturn(Optional.of(otherNoFcm));
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@WithMockUser(username = "2", roles = "GUARDIAN")
@DisplayName("POST /api/v1/shared/call/end - Guardian juga bisa end call")
void endCall_asGuardian_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 1L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
} }
} }
} }

View File

@ -92,9 +92,9 @@ class PairingControllerTest {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
InviteUserRequest req = new InviteUserRequest(); InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("INVALID999"); req.setPairingCode("BAD99999");
when(pairingService.inviteUser(2L, "INVALID999")) when(pairingService.inviteUser(2L, "BAD99999"))
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan")); .thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
mockMvc.perform(post("/api/v1/shared/pairing/invite") mockMvc.perform(post("/api/v1/shared/pairing/invite")

View File

@ -0,0 +1,128 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("CallNotificationService Unit Tests")
class CallNotificationServiceTest {
@Mock
private FcmService fcmService;
@Mock
private UserRepository userRepository;
@InjectMocks
private CallNotificationService service;
private User caller;
private User receiver;
private CallNotifyRequest request;
@BeforeEach
void setUp() {
caller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.build();
receiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken("receiver-token")
.build();
request = new CallNotifyRequest();
request.setReceiverId(2L);
request.setChannelName("call_1_2");
request.setAgoraToken("agora-token");
request.setReceiverUid(1002);
}
@Test
@DisplayName("notifyIncomingCall sends high priority FCM when receiver has token")
void notifyIncomingCall_receiverHasToken_shouldSendFcm() {
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Notifikasi panggilan berhasil dikirim", message);
verify(fcmService).sendHighPriority(
eq("receiver-token"),
eq("Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
}
@Test
@DisplayName("notifyIncomingCall skips FCM when receiver token is missing")
void notifyIncomingCall_receiverHasNoToken_shouldSkipFcm() {
receiver.setFcmToken(null);
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyIncomingCall throws 404 when caller is missing")
void notifyIncomingCall_missingCaller_shouldThrow() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> service.notifyIncomingCall(1L, request));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyCallEnded sends FCM when other user has token")
void notifyCallEnded_otherHasToken_shouldSendFcm() {
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
service.notifyCallEnded(1L, 2L);
verify(fcmService).sendToToken(
eq("receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
@Test
@DisplayName("notifyCallEnded skips repository lookup when otherId is null")
void notifyCallEnded_nullOtherId_shouldReturn() {
service.notifyCallEnded(1L, null);
verify(userRepository, never()).findById(2L);
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}

View File

@ -7,6 +7,7 @@ import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.PairingRelation; import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.User; import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus; import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.repository.*; import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster; import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -232,6 +233,35 @@ class LocationServiceTest {
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257); assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
} }
@Test
@DisplayName("getLocationHistoryForGuardian - harus ambil histori milik User yang dipair, bukan guardian")
void getLocationHistoryForGuardian_activePairing_shouldUsePairedUserId() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page);
Page<LocationResponse> result = locationService.getLocationHistoryForGuardian(1L, pageable);
assertThat(result.getContent()).hasSize(1);
verify(locationHistoryRepository).findByUserIdOrderByCreatedAtDesc(2L, pageable);
verify(locationHistoryRepository, never()).findByUserIdOrderByCreatedAtDesc(1L, pageable);
}
@Test
@DisplayName("getLocationHistoryForGuardian - tanpa pairing aktif harus error pairing")
void getLocationHistoryForGuardian_noPairing_shouldThrow() {
Pageable pageable = PageRequest.of(0, 10);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> locationService.getLocationHistoryForGuardian(1L, pageable))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian belum terhubung");
}
// ===== haversineMeters TESTS ===== // ===== haversineMeters TESTS =====
@Test @Test

View File

@ -16,6 +16,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional; import java.util.Optional;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
@ -54,6 +55,8 @@ class PairingServiceTest {
.role("ROLE_USER") .role("ROLE_USER")
.displayName("User Test") .displayName("User Test")
.uniqueUserId("ABC123DEF456") .uniqueUserId("ABC123DEF456")
.pairingCode("AB12CD34")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.fcmToken("user-fcm-token") .fcmToken("user-fcm-token")
.build(); .build();
} }
@ -66,7 +69,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian)); when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user)); when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> { when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
PairingRelation p = inv.getArgument(0); PairingRelation p = inv.getArgument(0);
@ -74,7 +77,7 @@ class PairingServiceTest {
return p; return p;
}); });
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456"); PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
assertThat(result).isNotNull(); assertThat(result).isNotNull();
verify(pairingRelationRepository).save(any(PairingRelation.class)); verify(pairingRelationRepository).save(any(PairingRelation.class));
@ -113,7 +116,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian)); when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty()); when(userRepository.findByPairingCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID")) assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
.isInstanceOf(ResourceNotFoundException.class) .isInstanceOf(ResourceNotFoundException.class)
@ -124,14 +127,17 @@ class PairingServiceTest {
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException") @DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
void inviteUser_targetNotUser_shouldThrow() { void inviteUser_targetNotUser_shouldThrow() {
User anotherGuardian = User.builder() User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build(); .id(3L).role("ROLE_GUARDIAN")
.pairingCode("GRD00001")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.build();
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian)); when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian)); when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001")) assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
.isInstanceOf(PairingException.class) .isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User"); .hasMessageContaining("bukan milik User");
} }
@ -142,10 +148,10 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian)); when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user)); when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true); when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456")) assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34"))
.isInstanceOf(PairingException.class) .isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain"); .hasMessageContaining("sudah dipair dengan Guardian lain");
} }

View File

@ -49,3 +49,13 @@ hs_err_pid*.log
# Android SDK path (generated by Android Studio) # Android SDK path (generated by Android Studio)
android/local.properties android/local.properties
# Local Python/YOLO export artifacts - do not commit
.venv*/
yolov8n.onnx
yolov8n.pt
yolov8n_saved_model/
calibration_image_sample_data_*.npy
devtools_options.yaml
android/app/src/main/java/dev/flutter/plugins/
android/app/src/main/java/io/flutter/plugins/

View File

@ -31,6 +31,10 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
androidResources {
noCompress += listOf("tflite", "lite")
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.

View File

@ -1,10 +1,80 @@
person person
bicycle
car car
motorcycle motorcycle
bicycle airplane
bus bus
train
truck truck
chair boat
traffic light
fire hydrant
stop sign
parking meter
bench bench
door bird
stairs cat
dog
horse
sheep
cow
elephant
bear
zebra
giraffe
backpack
umbrella
handbag
tie
suitcase
frisbee
skis
snowboard
sports ball
kite
baseball bat
baseball glove
skateboard
surfboard
tennis racket
bottle
wine glass
cup
fork
knife
spoon
bowl
banana
apple
sandwich
orange
broccoli
carrot
hot dog
pizza
donut
cake
chair
couch
potted plant
bed
dining table
toilet
tv
laptop
mouse
remote
keyboard
cell phone
microwave
oven
toaster
sink
refrigerator
book
clock
vase
scissors
teddy bear
hair drier
toothbrush

View File

@ -19,37 +19,81 @@ class WalkGuideApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
routerConfig: appRouter, routerConfig: appRouter,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: seed), colorScheme: ColorScheme.fromSeed(
scaffoldBackgroundColor: const Color(0xFFF8FAFC), seedColor: seed,
textTheme: GoogleFonts.interTextTheme(), brightness: Brightness.light,
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Colors.white,
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.white,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
), scaffoldBackgroundColor: const Color(0xFFF4F7FB),
inputDecorationTheme: InputDecorationTheme( textTheme: GoogleFonts.interTextTheme(),
filled: true, pageTransitionsTheme: const PageTransitionsTheme(
fillColor: Colors.white, builders: {
border: OutlineInputBorder( TargetPlatform.android: ZoomPageTransitionsBuilder(),
borderRadius: BorderRadius.circular(10), TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)), TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
), ),
enabledBorder: OutlineInputBorder( appBarTheme: const AppBarTheme(
borderRadius: BorderRadius.circular(10), centerTitle: false,
borderSide: const BorderSide(color: Color(0xFFE2E8F0)), backgroundColor: Color(0xFFF4F7FB),
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 76,
backgroundColor: Colors.white.withValues(alpha: 0.96),
indicatorColor: const Color(0xFFE0E7FF),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 50),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: seed,
textStyle: const TextStyle(fontWeight: FontWeight.w800),
side: const BorderSide(color: Color(0xFFCBD5E1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: seed, width: 1.5),
),
), ),
),
), ),
), ),
); );

View File

@ -2,8 +2,33 @@ import 'package:go_router/go_router.dart';
import '../core/constants/app_constants.dart'; import '../core/constants/app_constants.dart';
import '../features/activity_log/activity_log_screen.dart' as activity; import '../features/activity_log/activity_log_screen.dart' as activity;
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark;
import '../features/auth/login_screen.dart' as auth_login;
import '../features/auth/register_screen.dart' as auth_register;
import '../features/auth/splash_screen.dart' as auth_splash;
import '../features/call/call_screen.dart' as call;
import '../features/guardian_dashboard/guardian_activity_log_screen.dart'
as guardian_logs;
import '../features/guardian_dashboard/guardian_ai_config_screen.dart'
as guardian_ai;
import '../features/guardian_dashboard/guardian_map_screen.dart'
as guardian_map;
import '../features/guardian_dashboard/guardian_send_notification_screen.dart'
as guardian_send;
import '../features/guardian_dashboard/guardian_settings_screen.dart'
as guardian_settings;
import '../features/guardian_dashboard/guardian_tools_screen.dart'
as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home;
import '../features/navigation_mode/navigation_mode_screen.dart' as nav;
import '../features/notifications/notification_screen.dart' as notifications; import '../features/notifications/notification_screen.dart' as notifications;
import '../features/screens.dart'; import '../features/pairing/pairing_screens.dart' as pairing;
import '../features/server_connect/server_connect_server.dart'
as server_connect;
import '../features/settings/user_settings_screen.dart' as user_settings;
import '../features/sos/sos_screen.dart' as sos;
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide;
import '../shared/widgets/app_shells.dart'; import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter( final GoRouter appRouter = GoRouter(
@ -25,19 +50,23 @@ final GoRouter appRouter = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: '/server-connect', path: '/server-connect',
builder: (_, __) => const ServerConnectScreen()), builder: (_, __) => const server_connect.ServerConnectScreen()),
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
GoRoute( GoRoute(
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()), path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
GoRoute(
path: '/register',
builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute(
path: '/incoming-call',
builder: (_, __) => const call.IncomingCallScreen()),
ShellRoute( ShellRoute(
builder: (_, __, child) => UserShell(child: child), builder: (_, __, child) => UserShell(child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: '/user/walkguide', path: '/user/walkguide',
builder: (_, __) => const WalkGuideScreen()), builder: (_, __) => const walk_guide.WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()), GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
GoRoute( GoRoute(
path: '/user/activity', path: '/user/activity',
builder: (_, __) => const activity.ActivityLogScreen()), builder: (_, __) => const activity.ActivityLogScreen()),
@ -46,17 +75,18 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const notifications.NotificationScreen()), builder: (_, __) => const notifications.NotificationScreen()),
GoRoute( GoRoute(
path: '/user/navigation', path: '/user/navigation',
builder: (_, __) => const NavigationModeScreen()), builder: (_, __) => const nav.NavigationModeScreen()),
GoRoute( GoRoute(
path: '/user/settings', path: '/user/settings',
builder: (_, __) => const UserSettingsScreen()), builder: (_, __) => const user_settings.UserSettingsScreen()),
GoRoute( GoRoute(
path: '/user/pairing', path: '/user/pairing',
builder: (_, __) => const UserPairingScreen()), builder: (_, __) => const pairing.UserPairingScreen()),
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()), GoRoute(
path: '/user/call', builder: (_, __) => const call.CallScreen()),
GoRoute( GoRoute(
path: '/user/benchmark', path: '/user/benchmark',
builder: (_, __) => const AiBenchmarkScreen()), builder: (_, __) => const benchmark.AiBenchmarkScreen()),
], ],
), ),
ShellRoute( ShellRoute(
@ -64,37 +94,39 @@ final GoRouter appRouter = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: '/guardian/dashboard', path: '/guardian/dashboard',
builder: (_, __) => const GuardianDashboardScreen()), builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
GoRoute( GoRoute(
path: '/guardian/map', path: '/guardian/map',
builder: (_, __) => const GuardianMapScreen()), builder: (_, __) => const guardian_map.GuardianMapScreen()),
GoRoute( GoRoute(
path: '/guardian/logs', path: '/guardian/logs',
builder: (_, __) => const GuardianActivityLogScreen()), builder: (_, __) =>
const guardian_logs.GuardianActivityLogScreen()),
GoRoute( GoRoute(
path: '/guardian/send-notif', path: '/guardian/send-notif',
builder: (_, __) => const GuardianSendNotifScreen()), builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
GoRoute( GoRoute(
path: '/guardian/ai-config', path: '/guardian/ai-config',
builder: (_, __) => const GuardianAiConfigScreen()), builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
GoRoute( GoRoute(
path: '/guardian/voice-cmd', path: '/guardian/voice-cmd',
builder: (_, __) => const GuardianVoiceCmdScreen()), builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
GoRoute( GoRoute(
path: '/guardian/shortcuts', path: '/guardian/shortcuts',
builder: (_, __) => const GuardianShortcutScreen()), builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
GoRoute( GoRoute(
path: '/guardian/geofence', path: '/guardian/geofence',
builder: (_, __) => const GuardianGeofenceScreen()), builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
GoRoute( GoRoute(
path: '/guardian/pairing', path: '/guardian/pairing',
builder: (_, __) => const GuardianPairingScreen()), builder: (_, __) => const pairing.GuardianPairingScreen()),
GoRoute( GoRoute(
path: '/guardian/settings', path: '/guardian/settings',
builder: (_, __) => const GuardianSettingsScreen()), builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()),
GoRoute( GoRoute(
path: '/guardian/benchmark', path: '/guardian/benchmark',
builder: (_, __) => const AiBenchmarkScreen()), builder: (_, __) => const benchmark.AiBenchmarkScreen()),
], ],
), ),
], ],

View File

@ -23,12 +23,14 @@ class DetectionResult {
final double confidence; final double confidence;
final ObstacleDirection direction; final ObstacleDirection direction;
final String estimatedDistance; final String estimatedDistance;
final BoundingBox? box;
const DetectionResult({ const DetectionResult({
required this.label, required this.label,
required this.confidence, required this.confidence,
required this.direction, required this.direction,
required this.estimatedDistance, required this.estimatedDistance,
this.box,
}); });
String get directionName => direction.name.toUpperCase(); String get directionName => direction.name.toUpperCase();
@ -39,7 +41,7 @@ class DetectionResult {
ObstacleDirection.center => 'tengah', ObstacleDirection.center => 'tengah',
ObstacleDirection.right => 'kanan', ObstacleDirection.right => 'kanan',
}; };
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.'; return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
} }
} }
@ -68,7 +70,7 @@ class ObstacleAnalyzer {
ObstacleDirection.center => 'depan', ObstacleDirection.center => 'depan',
ObstacleDirection.right => 'kanan', ObstacleDirection.right => 'kanan',
}; };
return 'Hati-hati, ${result.label} di $directionLabel. ' return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
'Jarak ${result.estimatedDistance}.'; 'Jarak ${result.estimatedDistance}.';
} }
@ -90,7 +92,12 @@ class ObstacleAnalyzer {
final bi = order.indexOf(b.estimatedDistance); final bi = order.indexOf(b.estimatedDistance);
final aRank = ai == -1 ? order.length : ai; final aRank = ai == -1 ? order.length : ai;
final bRank = bi == -1 ? order.length : bi; final bRank = bi == -1 ? order.length : bi;
return aRank.compareTo(bRank); final distanceCompare = aRank.compareTo(bRank);
if (distanceCompare != 0) return distanceCompare;
final directionCompare =
_directionRisk(a.direction).compareTo(_directionRisk(b.direction));
if (directionCompare != 0) return directionCompare;
return b.confidence.compareTo(a.confidence);
}); });
return sorted.first; return sorted.first;
} }
@ -102,15 +109,43 @@ class ObstacleAnalyzer {
return detections.where((d) => d.confidence >= threshold).toList(); return detections.where((d) => d.confidence >= threshold).toList();
} }
DetectionResult analyzeFallback({ int _directionRisk(ObstacleDirection direction) {
String label = 'person', return switch (direction) {
double confidence = 0.86, ObstacleDirection.center => 0,
}) { ObstacleDirection.left => 1,
return DetectionResult( ObstacleDirection.right => 1,
label: label, };
confidence: confidence, }
direction: ObstacleDirection.center,
estimatedDistance: 'Close (1-2m)', static String spokenLabel(String label) {
); return switch (label.toLowerCase()) {
'person' => 'orang',
'bicycle' => 'sepeda',
'car' => 'mobil',
'motorcycle' => 'motor',
'airplane' => 'pesawat',
'bus' => 'bus',
'train' => 'kereta',
'truck' => 'truk',
'boat' => 'perahu',
'traffic light' => 'lampu lalu lintas',
'fire hydrant' => 'hidran',
'stop sign' => 'rambu berhenti',
'parking meter' => 'meter parkir',
'bench' => 'bangku',
'chair' => 'kursi',
'couch' => 'sofa',
'potted plant' => 'pot tanaman',
'dining table' => 'meja',
'tv' => 'televisi',
'backpack' => 'tas',
'umbrella' => 'payung',
'handbag' => 'tas tangan',
'suitcase' => 'koper',
'skateboard' => 'papan skate',
'surfboard' => 'papan selancar',
'sports ball' => 'bola',
_ => label,
};
} }
} }

View File

@ -18,11 +18,31 @@ class YoloDetector {
YoloTensorType? _outputType; YoloTensorType? _outputType;
bool _ready = false; bool _ready = false;
String? _lastError; String? _lastError;
int _lastDecodedCount = 0;
int _lastConfidenceCount = 0;
int _lastObstacleCount = 0;
int _lastKeptCount = 0;
int _lastInferenceMs = 0;
String? _lastBestLabel;
double? _lastBestConfidence;
YoloDetector(this._analyzer); YoloDetector(this._analyzer);
bool get isReady => _ready && _runtime != null; bool get isReady => _ready && _runtime != null;
String? get lastError => _lastError; String? get lastError => _lastError;
String get diagnosticsSummary {
if (!isReady) {
return _lastError == null
? 'YOLO runtime belum aktif'
: 'YOLO runtime error: $_lastError';
}
final bestLabel = _lastBestLabel;
final bestConfidence = _lastBestConfidence;
final bestText = bestLabel == null || bestConfidence == null
? 'best: none'
: 'best: $bestLabel ${(bestConfidence * 100).toStringAsFixed(0)}%';
return 'frames ok, decoded $_lastDecodedCount, >=threshold $_lastConfidenceCount, obstacle $_lastObstacleCount, kept $_lastKeptCount, $bestText, ${_lastInferenceMs}ms';
}
Future<void> init() async { Future<void> init() async {
dispose(); dispose();
@ -47,55 +67,64 @@ class YoloDetector {
_ready = true; _ready = true;
} catch (e) { } catch (e) {
_lastError = e.toString(); _lastError = e.toString();
debugPrint('YOLO fallback mode: $e'); debugPrint('YOLO runtime initialization skipped: $e');
_ready = false; _ready = false;
} }
} }
Future<DetectionResult?> detect( Future<DetectionResult?> detect(
CameraImage image, { CameraImage image, {
double confidenceThreshold = 0.45, double confidenceThreshold = 0.25,
}) async { }) async {
if (!isReady) return detectFallback(); if (!isReady) return null;
try { try {
final stopwatch = Stopwatch()..start();
final input = _buildCameraInput(image); final input = _buildCameraInput(image);
final output = Uint8List(_runtime!.outputByteLength); final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output); _runtime!.run(input, output);
final detections = _decodeDetections(output); final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered = final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold); _analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered); final kept = _nonMaxSuppression(filtered);
_lastInferenceMs = stopwatch.elapsedMilliseconds;
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) { } catch (e) {
_lastError = e.toString(); _lastError = e.toString();
debugPrint('YOLO inference fallback: $e'); debugPrint('YOLO inference skipped: $e');
return detectFallback(); return null;
} }
} }
Future<DetectionResult?> detectSynthetic({ Future<DetectionResult?> detectSynthetic({
double confidenceThreshold = 0.25, double confidenceThreshold = 0.25,
}) async { }) async {
if (!isReady) return detectFallback(); if (!isReady) return null;
try { try {
final input = _buildSyntheticInput(); final input = _buildSyntheticInput();
final output = Uint8List(_runtime!.outputByteLength); final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output); _runtime!.run(input, output);
final detections = _decodeDetections(output); final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered = final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold); _analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered) ?? detectFallback(); final kept = _nonMaxSuppression(filtered);
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) { } catch (e) {
_lastError = e.toString(); _lastError = e.toString();
debugPrint('YOLO synthetic fallback: $e'); debugPrint('YOLO synthetic skipped: $e');
return detectFallback(); return null;
} }
} }
Future<DetectionResult?> detectFallback() async {
final label = _labels.isNotEmpty ? _labels.first : 'person';
return _analyzer.analyzeFallback(label: label);
}
void dispose() { void dispose() {
_runtime?.close(); _runtime?.close();
_runtime = null; _runtime = null;
@ -104,6 +133,26 @@ class YoloDetector {
_inputType = null; _inputType = null;
_outputType = null; _outputType = null;
_ready = false; _ready = false;
_lastDecodedCount = 0;
_lastConfidenceCount = 0;
_lastObstacleCount = 0;
_lastKeptCount = 0;
_lastInferenceMs = 0;
_lastBestLabel = null;
_lastBestConfidence = null;
}
void _recordDecoded(List<DetectionResult> detections) {
_lastDecodedCount = detections.length;
_lastBestLabel = null;
_lastBestConfidence = null;
for (final detection in detections) {
if (_lastBestConfidence == null ||
detection.confidence > _lastBestConfidence!) {
_lastBestLabel = detection.label;
_lastBestConfidence = detection.confidence;
}
}
} }
Uint8List _buildCameraInput(CameraImage image) { Uint8List _buildCameraInput(CameraImage image) {
@ -292,7 +341,7 @@ class YoloDetector {
} }
if (type == YoloTensorType.float16) { if (type == YoloTensorType.float16) {
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare; // Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
// fallback keeps the app usable if an unsupported variant is selected. // Keep preprocessing explicit if an unsupported variant is selected.
throw StateError('Float16 YOLO output is not supported yet'); throw StateError('Float16 YOLO output is not supported yet');
} }
throw StateError('Unsupported YOLO output type: $type'); throw StateError('Unsupported YOLO output type: $type');
@ -484,19 +533,164 @@ class YoloDetector {
confidence: confidence, confidence: confidence,
direction: _analyzer.analyzeDirection(box), direction: _analyzer.analyzeDirection(box),
estimatedDistance: _analyzer.estimateDistance(box), estimatedDistance: _analyzer.estimateDistance(box),
box: box,
); );
} }
List<DetectionResult> _nonMaxSuppression(
List<DetectionResult> detections, {
double iouThreshold = 0.45,
int maxDetections = 8,
}) {
final candidates = detections
.where((d) => d.box != null && _isObstacleLabel(d.label))
.toList()
..sort((a, b) => b.confidence.compareTo(a.confidence));
final selected = <DetectionResult>[];
for (final detection in candidates) {
final box = detection.box;
if (box == null) continue;
final overlaps = selected.any((kept) =>
kept.label == detection.label &&
kept.box != null &&
_iou(box, kept.box!) > iouThreshold);
if (overlaps) continue;
selected.add(detection);
if (selected.length >= maxDetections) break;
}
return selected;
}
bool _isObstacleLabel(String label) {
return _walkGuideObstacleLabels.contains(label.toLowerCase());
}
double _iou(BoundingBox a, BoundingBox b) {
final left = math.max(a.left, b.left);
final top = math.max(a.top, b.top);
final right = math.min(a.right, b.right);
final bottom = math.min(a.bottom, b.bottom);
final width = math.max(0.0, right - left);
final height = math.max(0.0, bottom - top);
final intersection = width * height;
final union = a.width * a.height + b.width * b.height - intersection;
if (union <= 0) return 0;
return intersection / union;
}
} }
const Set<String> _walkGuideObstacleLabels = {
'person',
'bicycle',
'car',
'motorcycle',
'bus',
'train',
'truck',
'traffic light',
'fire hydrant',
'stop sign',
'parking meter',
'bench',
'backpack',
'umbrella',
'handbag',
'suitcase',
'chair',
'couch',
'potted plant',
'dining table',
'tv',
'laptop',
'cell phone',
'bottle',
'cup',
'book',
};
const Map<int, String> _cocoObstacleLabels = { const Map<int, String> _cocoObstacleLabels = {
0: 'person', 0: 'person',
1: 'bicycle', 1: 'bicycle',
2: 'car', 2: 'car',
3: 'motorcycle', 3: 'motorcycle',
4: 'airplane',
5: 'bus', 5: 'bus',
6: 'train',
7: 'truck', 7: 'truck',
8: 'boat',
9: 'traffic light',
10: 'fire hydrant',
11: 'stop sign',
12: 'parking meter',
13: 'bench', 13: 'bench',
14: 'bird',
15: 'cat',
16: 'dog',
17: 'horse',
18: 'sheep',
19: 'cow',
20: 'elephant',
21: 'bear',
22: 'zebra',
23: 'giraffe',
24: 'backpack',
25: 'umbrella',
26: 'handbag',
27: 'tie',
28: 'suitcase',
29: 'frisbee',
30: 'skis',
31: 'snowboard',
32: 'sports ball',
33: 'kite',
34: 'baseball bat',
35: 'baseball glove',
36: 'skateboard',
37: 'surfboard',
38: 'tennis racket',
39: 'bottle',
40: 'wine glass',
41: 'cup',
42: 'fork',
43: 'knife',
44: 'spoon',
45: 'bowl',
46: 'banana',
47: 'apple',
48: 'sandwich',
49: 'orange',
50: 'broccoli',
51: 'carrot',
52: 'hot dog',
53: 'pizza',
54: 'donut',
55: 'cake',
56: 'chair', 56: 'chair',
57: 'couch',
58: 'potted plant',
59: 'bed',
60: 'dining table',
61: 'toilet',
62: 'tv',
63: 'laptop',
64: 'mouse',
65: 'remote',
66: 'keyboard',
67: 'cell phone',
68: 'microwave',
69: 'oven',
70: 'toaster',
71: 'sink',
72: 'refrigerator',
73: 'book',
74: 'clock',
75: 'vase',
76: 'scissors',
77: 'teddy bear',
78: 'hair drier',
79: 'toothbrush',
}; };
class _InputInfo { class _InputInfo {

View File

@ -1,21 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; // Wajib import ini
class ApiService { class ApiService {
static String get baseUrl { static const baseUrl = String.fromEnvironment(
if (kIsWeb) { 'WALKGUIDE_API_BASE_URL',
// Jika di Chrome/Web, tembak localhost langsung defaultValue: 'http://202.46.28.160:8080/api/v1',
return 'http://localhost:8080/api'; );
} else {
// Jika di Emulator Android, tembak IP khusus 10.0.2.2
return 'http://10.0.2.2:8080/api';
}
}
final Dio _dio = Dio(BaseOptions( final Dio _dio = Dio(BaseOptions(
baseUrl: baseUrl, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5), connectTimeout: const Duration(seconds: 5),
// Penting buat Web agar tidak kena error CORS di sisi Client
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',

View File

@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
String friendlyErrorMessage(
Object error, {
required String fallback,
String? connectionHint,
}) {
if (error is DioException) {
return friendlyDioMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
);
}
return fallback;
}
String friendlyDioMessage(
DioException error, {
required String fallback,
String? connectionHint,
}) {
final backendMessage = _cleanBackendMessage(error.response?.data);
if (backendMessage != null) return backendMessage;
final status = error.response?.statusCode;
if (status == 401) return 'Sesi sudah habis atau data login salah.';
if (status == 403) return 'Akun kamu belum punya akses ke fitur ini.';
if (status == 404) return 'Data belum ditemukan.';
if (status == 409) return 'Data sudah dipakai. Coba gunakan data lain.';
if (status != null && status >= 500) {
return 'Server sedang bermasalah. Coba lagi sebentar.';
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Coba lagi.';
}
if (error.type == DioExceptionType.connectionError) {
return connectionHint ?? 'Tidak bisa terhubung ke server.';
}
return fallback;
}
String? _cleanBackendMessage(Object? data) {
Object? raw;
if (data is Map) {
raw = data['message'] ?? data['error'] ?? data['errorCode'];
} else if (data is String) {
raw = data;
}
final message = raw?.toString().trim();
if (message == null || message.isEmpty || _looksTechnical(message)) {
return null;
}
return message;
}
bool _looksTechnical(String message) {
final lower = message.toLowerCase();
const blocked = [
'exception',
'dioexception',
'typeerror',
'stacktrace',
'instance of',
'_jsonmap',
'socketexception',
'package:',
'null check operator',
'nosuchmethod',
'formatexception',
];
return blocked.any(lower.contains);
}
Future<T?> runFriendly<T>(
Future<T?> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
return await action();
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return null;
}
}
Future<bool> runFriendlyAction(
Future<void> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
await action();
return true;
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return false;
}
}

View File

@ -37,7 +37,7 @@ class WebSocketService {
bool get isConnected => _connected; bool get isConnected => _connected;
/// Connect ke WebSocket server. /// Connect ke WebSocket server.
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices). /// Dipanggil setelah login berhasil untuk membuka channel realtime.
Future<void> connect(String serverUrl) async { Future<void> connect(String serverUrl) async {
await disconnect(); await disconnect();

View File

@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
@ -44,26 +45,22 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
_loading = true; _loading = true;
_error = null; _error = null;
}); });
try { await runFriendlyAction(
final res = await _api () async {
.get('/user/activity-logs') final res = await _api
.timeout(const Duration(seconds: 10)); .get('/user/activity-logs')
final list = _extractList(res.data); .timeout(const Duration(seconds: 10));
final items = list.map(_LogItem.fromJson).toList(); final list = _extractList(res.data);
setState(() { final items = list.map(_LogItem.fromJson).toList();
_items = items; setState(() {
_applyFilter(_selectedFilter); _items = items;
}); _applyFilter(_selectedFilter);
} on DioException catch (e) { });
setState(() { },
_error = e.response?.data?['message']?.toString() ?? onError: (message) => setState(() => _error = message),
'Gagal memuat activity log.'; fallback: 'Gagal memuat activity log. Coba refresh lagi.',
}); );
} catch (e) { if (mounted) setState(() => _loading = false);
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
} }
List<Map<String, dynamic>> _extractList(dynamic responseBody) { List<Map<String, dynamic>> _extractList(dynamic responseBody) {

View File

@ -0,0 +1,290 @@
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/services/tts_service.dart';
import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
@override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
}
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
static const _runsKey = 'ai_benchmark_runs';
List<String> _models = const [];
String _selectedModel = AppConstants.yoloModelPath;
List<Map<String, dynamic>> _runs = const [];
bool _running = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final models = await _discoverTfliteModels();
final selected = await AppConstants.getSelectedYoloModelPath();
final prefs = await SharedPreferences.getInstance();
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
setState(() {
_models = models.isEmpty ? [selected] : models;
_selectedModel = _models.contains(selected) ? selected : _models.first;
_runs = rawRuns
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack('Model aktif: ${value.split('/').last}');
}
Future<void> _runBenchmark() async {
setState(() => _running = true);
final started = DateTime.now();
final captureMs = await _measureCapture();
final inferenceWatch = Stopwatch()..start();
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
inferenceWatch.stop();
final label = detection?.label ?? 'no detection';
final direction = detection?.directionName ?? '-';
final distance = detection?.estimatedDistance ?? '-';
final text = detection == null
? 'Tidak ada obstacle di atas threshold.'
: 'Obstacle $label di $direction, jarak $distance';
final notifWatch = Stopwatch()..start();
final notificationText = text;
notifWatch.stop();
final ttsWatch = Stopwatch()..start();
try {
await sl<TtsService>()
.speakImmediate(notificationText)
.timeout(const Duration(seconds: 3));
} catch (_) {}
ttsWatch.stop();
final run = {
'time': started.toIso8601String(),
'model': _selectedModel,
'modelLoaded': modelLoaded,
'captureMs': captureMs,
'inferenceMs': inferenceWatch.elapsedMilliseconds,
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
'ttsMs': ttsWatch.elapsedMilliseconds,
'label': label,
'direction': direction,
};
final prefs = await SharedPreferences.getInstance();
final next = [
jsonEncode(run),
...((prefs.getStringList(_runsKey) ?? const []).take(24)),
];
await prefs.setStringList(_runsKey, next);
if (!mounted) return;
setState(() {
_runs = [run, ..._runs].take(25).toList();
_running = false;
});
}
Future<int> _measureCapture() async {
final watch = Stopwatch()..start();
CameraController? controller;
try {
final cameras =
await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) {
controller = CameraController(
cameras.first,
ResolutionPreset.low,
enableAudio: false,
);
await controller.initialize().timeout(const Duration(seconds: 5));
await controller.takePicture().timeout(const Duration(seconds: 5));
}
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _clearRuns() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_runsKey);
setState(() => _runs = const []);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
return FeaturePage(
title: 'AI Benchmark',
subtitle: 'Capture, inference, notification text, and TTS timing',
child: ListView(
children: [
DropdownButtonFormField<String>(
initialValue: _selectedModel,
decoration: const InputDecoration(labelText: 'Model file'),
items: [
for (final model in _models)
DropdownMenuItem(
value: model,
child: Text(model.split('/').last),
),
],
onChanged: _setModel,
),
if (!hasRealModel) ...[
const SizedBox(height: 10),
const _StatusBox(
success: false,
message:
'Belum ada file .tflite di assets/models. Tambahkan model lalu restart app.',
),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _running ? null : _runBenchmark,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow),
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _clearRuns,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear log'),
),
const SizedBox(height: 16),
for (final run in _runs) _BenchmarkCard(run: run),
if (_runs.isEmpty)
const FeatureEmptyPanel(
icon: Icons.speed,
title: 'Belum ada log',
message:
'Klik Run benchmark untuk mencatat capture, inference, notification, dan TTS.',
),
],
),
);
}
}
class _BenchmarkCard extends StatelessWidget {
final Map<String, dynamic> run;
const _BenchmarkCard({required this.run});
@override
Widget build(BuildContext context) {
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time == null
? 'Benchmark run'
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'not ready'})'),
Text('Capture: ${run['captureMs']} ms'),
Text('Model/inference: ${run['inferenceMs']} ms'),
Text('Notification text: ${run['notificationMs']} ms'),
Text('TTS start: ${run['ttsMs']} ms'),
Text('Result: ${run['label']} ${run['direction']}'),
],
),
);
}
}
class _StatusBox extends StatelessWidget {
final bool success;
final String message;
const _StatusBox({required this.success, required this.message});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
message,
style: TextStyle(
color: success ? const Color(0xFF166534) : const Color(0xFF991B1B),
),
),
),
);
}
}
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
} catch (_) {
return const [];
}
}
String _two(int value) => value.toString().padLeft(2, '0');

View File

@ -12,7 +12,8 @@ class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage); AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
@override @override
Future<Either<Failure, UserEntity>> login(String email, String password) async { Future<Either<Failure, UserEntity>> login(
String email, String password) async {
try { try {
// 1. Suruh data source nembak API // 1. Suruh data source nembak API
final userModel = await remoteDataSource.login(email, password); final userModel = await remoteDataSource.login(email, password);
@ -24,7 +25,21 @@ class AuthRepositoryImpl implements AuthRepository {
return Right(userModel); return Right(userModel);
} catch (e) { } catch (e) {
// 4. Balikin pesan error (Left) // 4. Balikin pesan error (Left)
return Left(ServerFailure(e.toString().replaceAll('Exception: ', ''))); return Left(ServerFailure(_safeAuthFailure(e)));
} }
} }
} }
String _safeAuthFailure(Object error) {
final message = error.toString().replaceFirst(RegExp(r'^\w+:\s*'), '').trim();
final lower = message.toLowerCase();
final technical = lower.contains('exception') ||
lower.contains('typeerror') ||
lower.contains('stacktrace') ||
lower.contains('instance of') ||
lower.contains('package:');
if (message.isEmpty || technical) {
return 'Login gagal. Periksa email dan password kamu.';
}
return message;
}

View File

@ -2,7 +2,6 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -11,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart'; import '../../app/app_cubit.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/offline_queue_service.dart'; import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
@ -65,27 +65,27 @@ class _LoginScreenState extends State<LoginScreen> {
return; return;
} }
setState(() => _loading = true); setState(() => _loading = true);
try { await runFriendlyAction(
final res = await sl<ApiClient>().dio.post('/auth/login', data: { () async {
'email': _email.text.trim(), final res = await sl<ApiClient>().dio.post('/auth/login', data: {
'password': _password.text, 'email': _email.text.trim(),
}); 'password': _password.text,
await _saveAuthAndRoute( });
context, Map<String, dynamic>.from(res.data['data'] as Map)); await _saveAuthAndRoute(
} on DioException catch (e) { context, Map<String, dynamic>.from(res.data['data'] as Map));
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal')); },
} catch (e) { onError: (message) => _snack(context, message),
_snack(context, 'Login gagal: $e'); fallback: 'Login gagal. Periksa email dan password kamu.',
} finally { connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
if (mounted) setState(() => _loading = false); );
} if (mounted) setState(() => _loading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _AuthFrame( return _AuthFrame(
title: 'Sign in', title: 'Sign in',
subtitle: 'Masuk sebagai Guardian atau User.', subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -147,42 +147,134 @@ class _AuthFrame extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Center( backgroundColor: const Color(0xFFEAF4FF),
child: SingleChildScrollView( body: Stack(
padding: const EdgeInsets.all(24), children: [
child: ConstrainedBox( const Positioned.fill(
constraints: const BoxConstraints(maxWidth: 460), child: DecoratedBox(
child: Card( decoration: BoxDecoration(
elevation: 0, gradient: LinearGradient(
shape: RoundedRectangleBorder( begin: Alignment.topLeft,
borderRadius: BorderRadius.circular(18), end: Alignment.bottomRight,
side: const BorderSide(color: Color(0xFFE2E8F0))), colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
), ),
), ),
), ),
), ),
), Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
), ),
); );
} }
@ -219,16 +311,14 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) { void _startPostLoginServices(String serverUrl) {
Future.microtask(() async { Future.microtask(() async {
try { await sl<WebSocketService>()
await sl<WebSocketService>() .connect(serverUrl)
.connect(serverUrl) .timeout(const Duration(seconds: 2));
.timeout(const Duration(seconds: 2)); await sl<OfflineQueueService>()
await sl<OfflineQueueService>() .syncPending(sl<ApiClient>())
.syncPending(sl<ApiClient>()) .timeout(const Duration(seconds: 3));
.timeout(const Duration(seconds: 3)); }).catchError((Object e) {
} catch (e) { debugPrint('Post-login services skipped: $e');
debugPrint('Post-login services skipped: $e');
}
}); });
} }
@ -238,19 +328,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message))); .showSnackBar(SnackBar(content: Text(message)));
} }
} }
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 401) {
return 'Email atau password salah.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
}

View File

@ -1 +1 @@
export '../../screens.dart'; export '../login_screen.dart';

View File

@ -1,11 +1,11 @@
// ignore_for_file: use_build_context_synchronously // ignore_for_file: use_build_context_synchronously
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -52,26 +52,26 @@ class _RegisterScreenState extends State<RegisterScreen> {
return; return;
} }
setState(() => _loading = true); setState(() => _loading = true);
try { await runFriendlyAction(
final res = await sl<ApiClient>().dio.post('/auth/register', data: { () async {
'displayName': _name.text.trim(), final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'email': _email.text.trim(), 'displayName': _name.text.trim(),
'password': _password.text, 'email': _email.text.trim(),
'role': _role, 'password': _password.text,
}); 'role': _role,
final data = Map<String, dynamic>.from(res.data['data'] as Map); });
if (!mounted) return; final data = Map<String, dynamic>.from(res.data['data'] as Map);
final prefs = await SharedPreferences.getInstance(); if (!mounted) return;
await prefs.setString('pending_login_email', _email.text.trim()); final prefs = await SharedPreferences.getInstance();
await _showRegisterSuccess(context, data); await prefs.setString('pending_login_email', _email.text.trim());
if (mounted) context.go('/login'); await _showRegisterSuccess(context, data);
} on DioException catch (e) { if (mounted) context.go('/login');
_snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal')); },
} catch (e) { onError: (message) => _snack(context, message),
_snack(context, 'Registrasi gagal: $e'); fallback: 'Registrasi gagal. Periksa data akun kamu.',
} finally { connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
if (mounted) setState(() => _loading = false); );
} if (mounted) setState(() => _loading = false);
} }
@override @override
@ -81,7 +81,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
subtitle: _step == 0 subtitle: _step == 0
? 'Who are you in the WalkGuide system?' ? 'Who are you in the WalkGuide system?'
: _role == 'USER' : _role == 'USER'
? 'User akan mendapat Unique ID untuk pairing.' ? 'User bisa membuat Pairing Code sementara setelah login.'
: 'Guardian dapat monitor dan konfigurasi User.', : 'Guardian dapat monitor dan konfigurasi User.',
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context), child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
); );
@ -298,42 +298,134 @@ class _AuthFrame extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Center( backgroundColor: const Color(0xFFEAF4FF),
child: SingleChildScrollView( body: Stack(
padding: const EdgeInsets.all(24), children: [
child: ConstrainedBox( const Positioned.fill(
constraints: const BoxConstraints(maxWidth: 460), child: DecoratedBox(
child: Card( decoration: BoxDecoration(
elevation: 0, gradient: LinearGradient(
shape: RoundedRectangleBorder( begin: Alignment.topLeft,
borderRadius: BorderRadius.circular(18), end: Alignment.bottomRight,
side: const BorderSide(color: Color(0xFFE2E8F0))), colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
), ),
), ),
), ),
), ),
), Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
), ),
); );
} }
@ -348,12 +440,12 @@ Future<void> _showRegisterSuccess(
final uniqueId = data['uniqueUserId']?.toString(); final uniqueId = data['uniqueUserId']?.toString();
final message = uniqueId == null || uniqueId.isEmpty final message = uniqueId == null || uniqueId.isEmpty
? 'Registrasi berhasil. Silakan login.' ? 'Registrasi berhasil. Silakan login.'
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.'; : 'Registrasi berhasil.\n\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
_snack( _snack(
context, context,
uniqueId == null uniqueId == null
? 'Registrasi berhasil.' ? 'Registrasi berhasil.'
: 'Registrasi berhasil. ID: $uniqueId'); : 'Registrasi berhasil. Buat Pairing Code setelah login.');
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@ -375,19 +467,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message))); .showSnackBar(SnackBar(content: Text(message)));
} }
} }
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 409) {
return 'Email sudah terdaftar. Gunakan email lain atau login.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di HP pakai IP server, bukan localhost.';
}
return fallback;
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/storage/secure_storage.dart'; import '../../core/storage/secure_storage.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -51,29 +52,33 @@ class _SplashScreenState extends State<SplashScreen>
} }
Future<void> _route() async { Future<void> _route() async {
try { final routed = await runFriendlyAction(
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. () async {
await Future.delayed(const Duration(milliseconds: 500)); // Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final storage = sl<SecureStorage>(); final storage = sl<SecureStorage>();
final token = final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3)); await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role = final role =
await storage.getUserRole().timeout(const Duration(seconds: 3)); await storage.getUserRole().timeout(const Duration(seconds: 3));
if (!mounted) return; if (!mounted) return;
if (token == null || role == null) { if (token == null || role == null) {
context.go('/login'); context.go('/login');
return; return;
} }
// Auto-login: arahkan ke home sesuai role. // Auto-login: arahkan ke home sesuai role.
context.go( context.go(role == 'ROLE_GUARDIAN'
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide'); ? '/guardian/dashboard'
} catch (_) { : '/user/walkguide');
if (mounted) context.go('/login'); },
} onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
} }
@override @override

View File

@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../app/injection_container.dart'; import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart'; import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
_error = null; _error = null;
_needsPairing = false; _needsPairing = false;
}); });
try { await runFriendlyAction(
// Cek pairing dulu () async {
final paired = await _hasActivePairing(); // Cek pairing dulu
if (!paired) { final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() { setState(() {
_needsPairing = true; _items = items;
_applyFilter(_selectedFilter);
_loading = false; _loading = false;
}); });
return; },
} onError: (message) => setState(() {
_error = message;
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
_loading = false; _loading = false;
}); }),
} on DioException catch (e) { fallback: 'Gagal memuat activity log. Coba refresh lagi.',
setState(() { );
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
_loading = false;
});
} catch (e) {
setState(() {
_error = 'Timeout / error: $e';
_loading = false;
});
}
} }
Future<bool> _hasActivePairing() async { Future<bool> _hasActivePairing() async {
try { final active = await runFriendly<bool>(
final res = await _api () async {
.get('/shared/pairing/status') final res = await _api
.timeout(const Duration(seconds: 5)); .get('/shared/pairing/status')
final data = res.data['data']; .timeout(const Duration(seconds: 5));
if (data is Map) return data['status'] == 'ACTIVE'; final data = res.data['data'];
} catch (_) {} if (data is Map) return data['status'] == 'ACTIVE';
return false; return false;
},
onError: (_) {},
fallback: 'Status pairing belum bisa dicek.',
);
return active ?? false;
} }
void _applyFilter(String filter) { void _applyFilter(String filter) {

View File

@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../app/injection_container.dart'; import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart'; import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
} }
} on DioException catch (e) { } on DioException catch (e) {
setState(() { setState(() {
_error = e.response?.data?['message']?.toString() ?? _error =
'Gagal memuat konfigurasi AI.'; friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
}); });
} catch (e) { } catch (e) {
setState(() => _error = 'Timeout / error: $e'); setState(
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(e.response?.data?['message']?.toString() ?? content: Text(friendlyDioMessage(e,
'Gagal menyimpan konfigurasi.'), fallback: 'Gagal menyimpan konfigurasi.')),
backgroundColor: const Color(0xFFDC2626), backgroundColor: const Color(0xFFDC2626),
), ),
); );
@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Error: $e'), content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: const Color(0xFFDC2626), backgroundColor: Color(0xFFDC2626),
), ),
); );
} }

View File

@ -0,0 +1,301 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget {
const GuardianMapScreen({super.key});
@override
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
}
class _GuardianMapScreenState extends State<GuardianMapScreen> {
bool _loading = true;
String? _error;
Map<String, dynamic>? _lastLocation;
List<Map<String, dynamic>> _history = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final dio = sl<ApiClient>().dio;
final current = await dio
.get('/guardian/user-location')
.timeout(const Duration(seconds: 8));
final history = await dio.get('/guardian/location-history',
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
final currentData = current.data is Map ? current.data['data'] : null;
final historyData = history.data is Map ? history.data['data'] : null;
_lastLocation =
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
final content = historyData is Map ? historyData['content'] : null;
_history = content is List
? content
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: const [];
},
onError: (message) => _error = message,
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Live Map',
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: Column(
children: [
Expanded(
flex: 3,
child: _GuardianMapCard(
location: _lastLocation,
history: _history,
),
),
const SizedBox(height: 12),
Expanded(
flex: 2,
child: _TimelineList(history: _history, onRetry: _load),
),
],
),
);
}
}
class _GuardianMapCard extends StatelessWidget {
final Map<String, dynamic>? location;
final List<Map<String, dynamic>> history;
const _GuardianMapCard({required this.location, required this.history});
@override
Widget build(BuildContext context) {
final points = _pointsFrom(history);
final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.walkguide.app',
),
if (points.length > 1)
PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4,
color: const Color(0xFF2563EB),
),
],
),
MarkerLayer(
markers: [
Marker(
point: center,
width: 54,
height: 54,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child:
const Icon(Icons.person_pin_circle, color: Colors.white),
),
),
],
),
],
),
);
}
}
class _TimelineList extends StatelessWidget {
final List<Map<String, dynamic>> history;
final VoidCallback onRetry;
const _TimelineList({required this.history, required this.onRetry});
@override
Widget build(BuildContext context) {
final segments = _segments(history);
if (segments.isEmpty) {
return FeatureEmptyPanel(
icon: Icons.timeline,
title: 'Belum ada timeline',
message:
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
action: OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
);
}
return ListView.separated(
itemCount: segments.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
);
}
}
class _TimelineCard extends StatelessWidget {
final _TimelineSegment segment;
const _TimelineCard({required this.segment});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(segment.title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(segment.subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
class _TimelineSegment {
final String title;
final String subtitle;
final IconData icon;
const _TimelineSegment({
required this.title,
required this.subtitle,
required this.icon,
});
}
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
if (items.length < 2) return const [];
final chunks = <_TimelineSegment>[];
for (var i = 0; i < items.length - 1; i += 6) {
final start = items[i];
final end = items[math.min(i + 5, items.length - 1)];
final distance = _distanceBetween(start, end);
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
final mode = speed > 2.8
? 'Naik kendaraan'
: speed > 1.1
? 'Berjalan cepat'
: 'Berjalan';
chunks.add(
_TimelineSegment(
title: '${_clock(start)} - ${_clock(end)} $mode',
subtitle:
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
),
);
}
return chunks.reversed.toList();
}
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
return history.map(_pointFrom).whereType<LatLng>().toList();
}
LatLng? _pointFrom(Map<String, dynamic>? item) {
if (item == null) return null;
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat == null || lng == null) return null;
return LatLng(lat, lng);
}
DateTime _time(Map<String, dynamic> item) {
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
}
String _clock(Map<String, dynamic> item) {
final time = _time(item).toLocal();
return DateFormat('HH:mm').format(time);
}
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
final start = _pointFrom(a);
final end = _pointFrom(b);
if (start == null || end == null) return 0;
return const Distance().as(LengthUnit.Meter, start, end);
}
double _avgSpeed(List<Map<String, dynamic>> items) {
final speeds = items
.map((e) => (e['speed'] as num?)?.toDouble())
.whereType<double>()
.where((speed) => speed >= 0)
.toList();
if (speeds.isEmpty) return 0;
return speeds.reduce((a, b) => a + b) / speeds.length;
}

View File

@ -1,18 +1,15 @@
export '../home/presentation/guardian_dashboard_screen.dart' export '../home/presentation/guardian_dashboard_screen.dart'
show GuardianDashboardScreen; show GuardianDashboardScreen;
export 'guardian_activity_log_screen.dart' export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
show
GuardianActivityLogScreen;
export 'guardian_ai_config_screen.dart' export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
show
GuardianAiConfigScreen;
export '../screens.dart' export 'guardian_map_screen.dart' show GuardianMapScreen;
show
GuardianMapScreen, export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
GuardianSendNotifScreen,
GuardianVoiceCmdScreen, export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
GuardianShortcutScreen,
GuardianGeofenceScreen; export 'guardian_tools_screen.dart'
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
const GuardianSendNotifScreen({super.key});
@override
State<GuardianSendNotifScreen> createState() =>
_GuardianSendNotifScreenState();
}
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
final _message = TextEditingController();
bool _loading = false;
@override
void dispose() {
_message.dispose();
super.dispose();
}
Future<void> _send() async {
final message = _message.text.trim();
if (message.isEmpty) {
_snack('Tulis pesan dulu.');
return;
}
setState(() => _loading = true);
await runFriendlyAction(
() async {
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
'notifType': 'TEXT',
'content': message,
}).timeout(const Duration(seconds: 8));
_message.clear();
_snack('Notifikasi terkirim ke User.');
},
onError: _snack,
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
);
if (mounted) setState(() => _loading = false);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Send Notification',
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _message,
minLines: 5,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading ? 'Sending...' : 'Send Message'),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,122 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSettingsScreen extends StatelessWidget {
const GuardianSettingsScreen({super.key});
Future<void> _logout(BuildContext context) async {
final appCubit = context.read<AppCubit>();
await sl<SecureStorage>().clearAll();
appCubit.clearSession();
unawaited(_notifyBackendLogout());
if (context.mounted) context.go('/login');
}
Future<void> _notifyBackendLogout() async {
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.post('/auth/logout')
.timeout(const Duration(seconds: 3));
},
onError: (_) {},
fallback: 'Logout server belum bisa dikonfirmasi.',
);
}
Future<void> _changeServer(BuildContext context) async {
await AppConstants.clearServerUrl();
await sl<SecureStorage>().clearAll();
if (context.mounted) context.go('/server-connect');
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Guardian Settings',
subtitle: 'Account, pairing, AI tools, and server',
child: ListView(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
const SizedBox(height: 18),
OutlinedButton.icon(
onPressed: () => _changeServer(context),
icon: const Icon(Icons.dns_outlined),
label: const Text('Change server'),
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
),
onPressed: () => _logout(context),
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: ListTile(
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}

View File

@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianVoiceCmdScreen extends StatelessWidget {
const GuardianVoiceCmdScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Voice Commands',
subtitle: 'Daftar voice command yang aktif untuk User',
endpoint: '/guardian/voice-commands',
icon: Icons.record_voice_over_outlined,
);
}
class GuardianShortcutScreen extends StatelessWidget {
const GuardianShortcutScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Hardware Shortcuts',
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
endpoint: '/guardian/shortcuts',
icon: Icons.keyboard_command_key_outlined,
);
}
class GuardianGeofenceScreen extends StatelessWidget {
const GuardianGeofenceScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Geofence',
subtitle: 'Area aman dan peringatan lokasi User',
endpoint: '/guardian/geofence',
icon: Icons.fence_outlined,
);
}
class _GuardianEndpointScreen extends StatefulWidget {
final String title;
final String subtitle;
final String endpoint;
final IconData icon;
const _GuardianEndpointScreen({
required this.title,
required this.subtitle,
required this.endpoint,
required this.icon,
});
@override
State<_GuardianEndpointScreen> createState() =>
_GuardianEndpointScreenState();
}
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
bool _loading = true;
String? _error;
List<Map<String, dynamic>> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.get(widget.endpoint,
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
_items = _extractList(res.data);
},
onError: (message) => _error = message,
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic body) {
final data = body is Map ? body['data'] : body;
final raw = data is Map ? data['content'] : data;
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: widget.title,
subtitle: widget.subtitle,
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: _items.isEmpty
? FeatureEmptyPanel(
icon: widget.icon,
title: 'Belum ada data',
message:
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
action: OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _EndpointCard(
icon: widget.icon,
item: _items[index],
),
),
),
);
}
}
class _EndpointCard extends StatelessWidget {
final IconData icon;
final Map<String, dynamic> item;
const _EndpointCard({required this.icon, required this.item});
@override
Widget build(BuildContext context) {
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
['description', 'action', 'shortcut', 'status', 'createdAt'],
) ??
'Data aktif';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
String? _firstText(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) {
final value = item[key]?.toString().trim();
if (value != null && value.isNotEmpty && value != 'null') return value;
}
return null;
}

View File

@ -9,6 +9,7 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -38,12 +39,14 @@ class _Step {
required this.point}); required this.point});
} }
// BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate // Navigation state is kept in a lightweight Cubit so the app uses one
// while staying consistent with the rest of screens.dart approach) // state-management family consistently.
enum _NavPhase { idle, locating, routing, navigating, error } enum _NavPhase { idle, locating, routing, navigating, error }
class _NavState extends ChangeNotifier { class _NavState extends Cubit<int> {
_NavState() : super(0);
_NavPhase phase = _NavPhase.idle; _NavPhase phase = _NavPhase.idle;
String statusText = 'Ketuk tombol lokasi atau cari tujuan.'; String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
LatLng? currentPosition; LatLng? currentPosition;
@ -59,9 +62,11 @@ class _NavState extends ChangeNotifier {
void _set(_NavPhase p, String status) { void _set(_NavPhase p, String status) {
phase = p; phase = p;
statusText = status; statusText = status;
notifyListeners(); _notify();
} }
void _notify() => emit(state + 1);
// locate // locate
Future<bool> locate() async { Future<bool> locate() async {
_set(_NavPhase.locating, 'Mencari lokasi GPS…'); _set(_NavPhase.locating, 'Mencari lokasi GPS…');
@ -85,7 +90,8 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'); _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
return false; return false;
} catch (e) { } catch (e) {
_set(_NavPhase.error, 'GPS error: $e'); _set(_NavPhase.error,
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
return false; return false;
} }
} }
@ -211,10 +217,11 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.navigating, _set(_NavPhase.navigating,
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.'); steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
notifyListeners(); _notify();
_startTracking(); _startTracking();
} catch (e) { } catch (e) {
_set(_NavPhase.error, 'Gagal mendapat rute: $e'); _set(_NavPhase.error,
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
} }
} }
@ -283,7 +290,7 @@ class _NavState extends ChangeNotifier {
currentPosition = LatLng(pos.latitude, pos.longitude); currentPosition = LatLng(pos.latitude, pos.longitude);
_reportToBackend(pos); _reportToBackend(pos);
_updateStep(); _updateStep();
notifyListeners(); _notify();
}); });
} }
@ -302,7 +309,7 @@ class _NavState extends ChangeNotifier {
final next = steps[currentStepIndex]; final next = steps[currentStepIndex];
statusText = next.instruction; statusText = next.instruction;
sl<TtsService>().speak(next.instruction); sl<TtsService>().speak(next.instruction);
notifyListeners(); _notify();
} }
} }
} }
@ -318,9 +325,9 @@ class _NavState extends ChangeNotifier {
} }
@override @override
void dispose() { Future<void> close() {
_posStream?.cancel(); _posStream?.cancel();
super.dispose(); return super.close();
} }
} }
@ -339,6 +346,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
final _searchFocus = FocusNode(); final _searchFocus = FocusNode();
StreamSubscription<int>? _navSubscription;
List<_Place> _suggestions = const []; List<_Place> _suggestions = const [];
bool _searchLoading = false; bool _searchLoading = false;
bool _showSuggestions = false; bool _showSuggestions = false;
@ -348,7 +356,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_navState.addListener(_onStateChange); _navSubscription = _navState.stream.listen((_) => _onStateChange());
WidgetsBinding.instance.addPostFrameCallback((_) => _init()); WidgetsBinding.instance.addPostFrameCallback((_) => _init());
} }
@ -433,8 +441,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_navState.removeListener(_onStateChange); _navSubscription?.cancel();
_navState.dispose(); _navState.close();
_searchCtrl.dispose(); _searchCtrl.dispose();
_searchFocus.dispose(); _searchFocus.dispose();
super.dispose(); super.dispose();

View File

@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
@ -36,24 +37,20 @@ class _NotificationScreenState extends State<NotificationScreen> {
_loading = true; _loading = true;
_error = null; _error = null;
}); });
try { await runFriendlyAction(
final res = await _api () async {
.get('/user/notifications') final res = await _api
.timeout(const Duration(seconds: 10)); .get('/user/notifications')
final list = _extractList(res.data); .timeout(const Duration(seconds: 10));
setState(() { final list = _extractList(res.data);
_items = list.map((e) => _NotifItem.fromJson(e)).toList(); setState(() {
}); _items = list.map((e) => _NotifItem.fromJson(e)).toList();
} on DioException catch (e) { });
setState(() { },
_error = e.response?.data?['message']?.toString() ?? onError: (message) => setState(() => _error = message),
'Gagal memuat notifikasi.'; fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
}); );
} catch (e) { if (mounted) setState(() => _loading = false);
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
} }
List<Map<String, dynamic>> _extractList(dynamic responseBody) { List<Map<String, dynamic>> _extractList(dynamic responseBody) {
@ -67,35 +64,37 @@ class _NotificationScreenState extends State<NotificationScreen> {
} }
Future<void> _markRead(int id) async { Future<void> _markRead(int id) async {
try { await runFriendlyAction(
await _api () async {
.put('/user/notifications/$id/read') await _api
.timeout(const Duration(seconds: 6)); .put('/user/notifications/$id/read')
setState(() { .timeout(const Duration(seconds: 6));
final idx = _items.indexWhere((n) => n.id == id); setState(() {
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true); final idx = _items.indexWhere((n) => n.id == id);
}); if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
} catch (_) {} });
},
onError: (_) {},
fallback: 'Gagal menandai notifikasi.',
);
} }
Future<void> _markAllRead() async { Future<void> _markAllRead() async {
setState(() => _markingAll = true); setState(() => _markingAll = true);
try { await runFriendlyAction(
await _api () async {
.put('/user/notifications/mark-all-read') await _api
.timeout(const Duration(seconds: 8)); .put('/user/notifications/mark-all-read')
setState(() { .timeout(const Duration(seconds: 8));
_items = _items.map((n) => n.copyWith(isRead: true)).toList(); setState(() {
}); _items = _items.map((n) => n.copyWith(isRead: true)).toList();
_snack('Semua notifikasi ditandai sudah dibaca.'); });
} on DioException catch (e) { _snack('Semua notifikasi ditandai sudah dibaca.');
_snack(e.response?.data?['message']?.toString() ?? },
'Gagal menandai semua dibaca.'); onError: _snack,
} catch (_) { fallback: 'Gagal menandai semua dibaca.',
_snack('Timeout. Coba lagi.'); );
} finally { if (mounted) setState(() => _markingAll = false);
if (mounted) setState(() => _markingAll = false);
}
} }
Future<void> _readAloud(_NotifItem notif) async { Future<void> _readAloud(_NotifItem notif) async {

View File

@ -2,10 +2,10 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart'; import '../../core/storage/secure_storage.dart';
@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// Ditampilkan ke akun ROLE_USER. // Ditampilkan ke akun ROLE_USER.
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share). // - Tampilkan pairing code sementara yang bisa di-copy/share.
// - Jika ada pending invite tampilkan nama Guardian + tombol Accept / Reject. // - Jika ada pending invite tampilkan nama Guardian + tombol Accept / Reject.
// - Jika sudah paired tampilkan info Guardian + tombol Unpair. // - Jika sudah paired tampilkan info Guardian + tombol Unpair.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget {
class _UserPairingScreenState extends State<UserPairingScreen> { class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId; String? _uniqueId;
String? _pairingCode;
DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds;
bool _regenerating = false;
@override @override
void initState() { void initState() {
@ -38,36 +42,87 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
Future<void> _loadUniqueId() async { Future<void> _loadUniqueId() async {
var value = await sl<SecureStorage>().getUniqueUserId(); var value = await sl<SecureStorage>().getUniqueUserId();
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
try { await runFriendlyAction(
final res = await sl<ApiClient>() () async {
.dio final res = await sl<ApiClient>()
.get('/user/profile') .dio
.timeout(const Duration(seconds: 5)); .get('/user/profile')
final data = res.data['data']; .timeout(const Duration(seconds: 5));
if (data is Map) value = data['uniqueUserId']?.toString(); final data = res.data['data'];
} catch (_) {} if (data is Map) value = data['uniqueUserId']?.toString();
},
onError: (_) {},
fallback: 'Profil belum bisa dimuat.',
);
} }
if (mounted) setState(() => _uniqueId = value); if (mounted) setState(() => _uniqueId = value);
} }
Future<void> _regeneratePairingCode() async {
setState(() => _regenerating = true);
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.post('/shared/pairing/code/regenerate')
.timeout(const Duration(seconds: 8));
_applyPairingCode(res.data['data']);
_snack(context, 'Pairing code baru sudah dibuat.');
},
onError: (message) => _snack(context, message),
fallback: 'Gagal membuat pairing code baru.',
);
if (mounted) setState(() => _regenerating = false);
}
void _applyPairingCode(dynamic raw) {
if (raw is! Map) return;
final expires = DateTime.tryParse(raw['expiresAt']?.toString() ?? '');
setState(() {
_pairingCode = raw['pairingCode']?.toString();
_pairingCodeExpiresAt = expires;
_pairingCodeSeconds = int.tryParse(raw['expiresInSeconds'].toString());
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _Page( return _Page(
title: 'Pairing', title: 'Pairing',
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.', subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_uniqueId == null || _uniqueId!.isEmpty) if (_pairingCode == null || _pairingCode!.isEmpty)
_InfoCard( _InfoCard(
title: 'Your Unique ID', title: 'Pairing Code',
value: 'Login sebagai User untuk melihat ID', value: 'Tap Generate',
icon: Icons.qr_code_2) icon: Icons.qr_code_2,
helper:
'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.')
else else
_InfoCard( _InfoCard(
title: 'Your Unique ID', title: 'Pairing Code',
value: _uniqueId!, value: _pairingCode!,
icon: Icons.qr_code_2), icon: Icons.qr_code_2,
helper:
'Valid ${_formatRemaining(_pairingCodeSeconds, _pairingCodeExpiresAt)}. Kode ini akan berubah dan kadaluarsa otomatis.'),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _regenerating ? null : _regeneratePairingCode,
icon: _regenerating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.autorenew),
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'),
),
if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Account ID: $_uniqueId',
style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)),
],
const SizedBox(height: 16), const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true), _PairingStatusCard(allowUserResponse: true),
], ],
@ -81,7 +136,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// Ditampilkan ke akun ROLE_GUARDIAN. // Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 12-char User ID. // - Input field 8-char temporary pairing code.
// - Tombol "Send Invite". // - Tombol "Send Invite".
// - Status card: jika sudah paired info User + tombol Unpair. // - Status card: jika sudah paired info User + tombol Unpair.
// Jika pending waiting state. // Jika pending waiting state.
@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
int _statusReload = 0; int _statusReload = 0;
Future<void> _invite() async { Future<void> _invite() async {
final uniqueId = _id.text.trim().toUpperCase(); final pairingCode = _id.text.trim().toUpperCase();
if (uniqueId.isEmpty || uniqueId.length != 12) { if (pairingCode.isEmpty || pairingCode.length != 8) {
_snack(context, 'Unique ID harus 12 karakter dari akun User.'); _snack(context, 'Pairing code harus 8 karakter dari akun User.');
return; return;
} }
setState(() => _loading = true); setState(() => _loading = true);
try { await runFriendlyAction(
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite', () async {
data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8)); final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
_snack( data: {
context, 'pairingCode': pairingCode
res.data['message']?.toString() ?? }).timeout(const Duration(seconds: 8));
'Invite terkirim. Minta User buka menu Pairing lalu Accept.'); _snack(
setState(() => _statusReload++); context,
} on DioException catch (e) { res.data['message']?.toString() ??
_snack( 'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
context, setState(() => _statusReload++);
_friendlyDioMessage(e, },
fallback: onError: (message) => _snack(context, message),
'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.')); fallback:
} on TimeoutException { 'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
_snack(context, );
'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.'); if (mounted) setState(() => _loading = false);
} catch (e) {
_snack(context, 'Invite gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _Page( return _Page(
title: 'Pair User', title: 'Pair User',
subtitle: 'Masukkan 12 karakter Unique ID milik User.', subtitle: 'Masukkan 8 karakter pairing code aktif dari User.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextField( TextField(
controller: _id, controller: _id,
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
maxLength: 12, maxLength: 8,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Unique User ID', labelText: 'Pairing Code',
hintText: 'Contoh: AB1C2D3E4F5G', hintText: 'Contoh: A7K9Q2M4',
prefixIcon: Icon(Icons.link), prefixIcon: Icon(Icons.link),
)), )),
FilledButton.icon( FilledButton.icon(
@ -192,50 +242,42 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
Future<void> _load() async { Future<void> _load() async {
setState(() => _loading = true); setState(() => _loading = true);
try { await runFriendlyAction(
final token = await sl<SecureStorage>().getAccessToken(); () async {
if (token == null || token.isEmpty) { final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.from(data) : null;
_active = data is Map && data['status'] == 'ACTIVE';
if (data is Map && data['status'] == 'ACTIVE') {
_active = true;
_status =
'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.';
} else if (data is Map && data['status'] == 'PENDING') {
_status = widget.allowUserResponse
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
} else {
_status = 'Belum pairing. Bagikan pairing code aktif ke Guardian.';
}
},
onError: (message) {
_active = false; _active = false;
_data = null; _data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; _status = message;
return; },
} fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
final res = await sl<ApiClient>() );
.dio if (mounted) setState(() => _loading = false);
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.from(data) : null;
_active = data is Map && data['status'] == 'ACTIVE';
if (data is Map && data['status'] == 'ACTIVE') {
_active = true;
_status =
'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.';
} else if (data is Map && data['status'] == 'PENDING') {
_status = widget.allowUserResponse
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
} else {
_status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.';
}
} on DioException catch (e) {
_active = false;
_data = null;
_status = _friendlyDioMessage(e,
fallback:
'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.');
} on TimeoutException {
_active = false;
_data = null;
_status =
'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.';
} catch (e) {
_active = false;
_data = null;
_status = 'Status pairing belum bisa dicek: $e';
} finally {
if (mounted) setState(() => _loading = false);
}
} }
Future<void> _respond(bool accept) async { Future<void> _respond(bool accept) async {
@ -245,25 +287,23 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
return; return;
} }
setState(() => _responding = true); setState(() => _responding = true);
try { await runFriendlyAction(
final res = () async {
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: { final res =
'pairingId': pairingId, await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
'accept': accept, 'pairingId': pairingId,
}).timeout(const Duration(seconds: 8)); 'accept': accept,
_snack( }).timeout(const Duration(seconds: 8));
context, _snack(
res.data['message']?.toString() ?? context,
(accept ? 'Pairing diterima.' : 'Pairing ditolak.')); res.data['message']?.toString() ??
await _load(); (accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
} on DioException catch (e) { await _load();
_snack(context, },
_friendlyDioMessage(e, fallback: 'Gagal merespons pairing.')); onError: (message) => _snack(context, message),
} on TimeoutException { fallback: 'Gagal merespons pairing.',
_snack(context, 'Server terlalu lama merespons pairing.'); );
} finally { if (mounted) setState(() => _responding = false);
if (mounted) setState(() => _responding = false);
}
} }
Future<void> _unpair() async { Future<void> _unpair() async {
@ -287,19 +327,19 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
); );
if (confirmed != true) return; if (confirmed != true) return;
setState(() => _responding = true); setState(() => _responding = true);
try { await runFriendlyAction(
await sl<ApiClient>() () async {
.dio await sl<ApiClient>()
.delete('/shared/pairing/unpair') .dio
.timeout(const Duration(seconds: 8)); .delete('/shared/pairing/unpair')
_snack(context, 'Pairing telah diputus.'); .timeout(const Duration(seconds: 8));
await _load(); _snack(context, 'Pairing telah diputus.');
} on DioException catch (e) { await _load();
_snack( },
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.')); onError: (message) => _snack(context, message),
} finally { fallback: 'Gagal memutus pairing.',
if (mounted) setState(() => _responding = false); );
} if (mounted) setState(() => _responding = false);
} }
@override @override
@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget {
final String title; final String title;
final String value; final String value;
final IconData icon; final IconData icon;
final String? helper;
const _InfoCard( const _InfoCard(
{required this.title, required this.value, required this.icon}); {required this.title,
required this.value,
required this.icon,
this.helper});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget {
Text(title), Text(title),
SelectableText(value, SelectableText(value,
style: const TextStyle( style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800)) fontSize: 22, fontWeight: FontWeight.w800)),
if (helper != null) ...[
const SizedBox(height: 4),
Text(helper!,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)),
],
])), ])),
], ],
), ),
@ -464,21 +514,11 @@ void _snack(BuildContext context, String message) {
} }
} }
String _friendlyDioMessage(DioException e, {required String fallback}) { String _formatRemaining(int? seconds, DateTime? expiresAt) {
final data = e.response?.data; final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
if (data is Map && data['message'] != null) return data['message'].toString(); if (value == null || value <= 0) return 'sudah kadaluarsa';
if (e.response?.statusCode == 401) { final minutes = value ~/ 60;
return 'Sesi login habis. Logout lalu login ulang.'; final secs = value % 60;
} if (minutes <= 0) return '$secs detik';
if (e.response?.statusCode == 403) { return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
} }

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -37,21 +38,22 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
_ok = false; _ok = false;
_message = null; _message = null;
}); });
try { await runFriendlyAction(
final clean = AppConstants.normalizeServerUrl(_url.text); () async {
final res = await Dio(BaseOptions( final clean = AppConstants.normalizeServerUrl(_url.text);
connectTimeout: AppConstants.pingTimeout, final res = await Dio(BaseOptions(
receiveTimeout: AppConstants.pingTimeout, connectTimeout: AppConstants.pingTimeout,
)).get('$clean/api/v1/auth/ping'); receiveTimeout: AppConstants.pingTimeout,
_ok = res.statusCode == 200 && res.data['success'] == true; )).get('$clean/api/v1/auth/ping');
_message = _ok _ok = res.statusCode == 200 && res.data['success'] == true;
? 'Server aktif dan siap dipakai.' _message = _ok
: 'Server merespons dengan format tidak valid.'; ? 'Server aktif dan siap dipakai.'
} catch (e) { : 'Server merespons dengan format tidak valid.';
_message = 'Tidak bisa terhubung. Periksa URL dan jaringan.'; },
} finally { onError: (message) => _message = message,
if (mounted) setState(() => _loading = false); fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
} );
if (mounted) setState(() => _loading = false);
} }
Future<void> _continue() async { Future<void> _continue() async {

View File

@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart'; import '../../app/app_cubit.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
@ -78,40 +79,46 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
try { await runFriendlyAction(
final res = () async {
await _api.get('/user/settings').timeout(const Duration(seconds: 6)); final res = await _api
final data = res.data['data']; .get('/user/settings')
if (data is Map) { .timeout(const Duration(seconds: 6));
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID'; final data = res.data['data'];
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0; if (data is Map) {
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9; _ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true; _ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true; _ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
} _warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
} catch (_) { _hapticEnabled = data['hapticEnabled'] as bool? ?? true;
// offline: tetap pakai default / nilai lokal }
} },
onError: (_) {},
fallback: 'Settings belum bisa dimuat.',
);
} }
Future<void> _loadPairing() async { Future<void> _loadPairing() async {
try { await runFriendlyAction(
final res = await _api () async {
.get('/shared/pairing/status') final res = await _api
.timeout(const Duration(seconds: 5)); .get('/shared/pairing/status')
final data = res.data['data']; .timeout(const Duration(seconds: 5));
if (data is Map) { final data = res.data['data'];
_paired = data['status'] == 'ACTIVE'; if (data is Map) {
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? ''; _paired = data['status'] == 'ACTIVE';
_pairingStatus = _paired final partner =
? 'Terhubung dengan $partner' data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
: data['status'] == 'PENDING' _pairingStatus = _paired
? 'Menunggu konfirmasi Guardian' ? 'Terhubung dengan $partner'
: 'Belum paired'; : data['status'] == 'PENDING'
} ? 'Menunggu konfirmasi Guardian'
} catch (_) { : 'Belum paired';
_pairingStatus = 'Tidak bisa cek status'; }
} },
onError: (_) => _pairingStatus = 'Tidak bisa cek status',
fallback: 'Tidak bisa cek status',
);
} }
Future<void> _save() async { Future<void> _save() async {
@ -122,34 +129,28 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
await sl<HapticService>().success(); await sl<HapticService>().success();
} }
try { await runFriendlyAction(
await _api.put('/user/settings', data: { () async {
'ttsLanguage': _ttsLanguage, await _api.put('/user/settings', data: {
'ttsPitch': _ttsPitch, 'ttsLanguage': _ttsLanguage,
'ttsSpeed': _ttsSpeed, 'ttsPitch': _ttsPitch,
'warnNoGuardian': _warnNoGuardian, 'ttsSpeed': _ttsSpeed,
'hapticEnabled': _hapticEnabled, 'warnNoGuardian': _warnNoGuardian,
}).timeout(const Duration(seconds: 8)); 'hapticEnabled': _hapticEnabled,
_snack('Settings tersimpan.'); }).timeout(const Duration(seconds: 8));
sl<TtsService>().speak('Settings disimpan.'); _snack('Settings tersimpan.');
} on DioException catch (e) { sl<TtsService>().speak('Settings disimpan.');
final msg = e.response?.data['message']?.toString() ?? },
'Server tidak merespons, settings lokal sudah diterapkan.'; onError: _snack,
_snack(msg); fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.',
} catch (_) { );
_snack('Settings lokal sudah diterapkan, gagal sync ke server.'); if (mounted) setState(() => _saving = false);
} finally {
if (mounted) setState(() => _saving = false);
}
} }
Future<void> _logout() async { Future<void> _logout() async {
await sl<SecureStorage>().clearAll(); await sl<SecureStorage>().clearAll();
context.read<AppCubit>().clearSession(); context.read<AppCubit>().clearSession();
_api _api.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore();
.post('/auth/logout')
.timeout(const Duration(seconds: 3))
.ignore();
if (mounted) context.go('/login'); if (mounted) context.go('/login');
} }

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
@ -94,15 +95,17 @@ class _SosScreenState extends State<SosScreen>
// API Calls // API Calls
Future<Position?> _getPosition() async { Future<Position?> _getPosition() async {
try { return runFriendly<Position>(
final permission = await Geolocator.requestPermission(); () async {
if (permission == LocationPermission.denied || final permission = await Geolocator.requestPermission();
permission == LocationPermission.deniedForever) return null; if (permission == LocationPermission.denied ||
return await Geolocator.getCurrentPosition() permission == LocationPermission.deniedForever) return null;
.timeout(const Duration(seconds: 6)); return await Geolocator.getCurrentPosition()
} catch (_) { .timeout(const Duration(seconds: 6));
return null; },
} onError: (_) {},
fallback: 'Lokasi belum bisa dibaca.',
);
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
@ -110,26 +113,24 @@ class _SosScreenState extends State<SosScreen>
_historyLoading = true; _historyLoading = true;
_historyError = null; _historyError = null;
}); });
try { await runFriendlyAction(
final res = await _api.get('/user/sos-events', () async {
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8)); final res = await _api.get('/user/sos-events',
final data = res.data['data']; queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
final content = data is Map ? data['content'] : null; final data = res.data['data'];
final items = content is List final content = data is Map ? data['content'] : null;
? content final items = content is List
.whereType<Map>() ? content
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e))) .whereType<Map>()
.toList() .map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
: <_SosEvent>[]; .toList()
setState(() => _events = items); : <_SosEvent>[];
} on DioException catch (e) { setState(() => _events = items);
final msg = e.response?.data?['message']?.toString(); },
setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.'); onError: (message) => setState(() => _historyError = message),
} catch (_) { fallback: 'Tidak bisa memuat riwayat SOS.',
setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.'); );
} finally { if (mounted) setState(() => _historyLoading = false);
if (mounted) setState(() => _historyLoading = false);
}
} }
Future<void> _confirmAndSend() async { Future<void> _confirmAndSend() async {
@ -178,25 +179,23 @@ class _SosScreenState extends State<SosScreen>
Future<void> _sendSos() async { Future<void> _sendSos() async {
setState(() => _sending = true); setState(() => _sending = true);
try { await runFriendlyAction(
final pos = await _getPosition(); () async {
await _api.post('/user/sos', data: { final pos = await _getPosition();
'triggerType': 'BUTTON', await _api.post('/user/sos', data: {
'lat': pos?.latitude, 'triggerType': 'BUTTON',
'lng': pos?.longitude, 'lat': pos?.latitude,
}); 'lng': pos?.longitude,
await sl<HapticService>().sosTriggered(); });
sl<TtsService>().speak('SOS terkirim ke Guardian.'); await sl<HapticService>().sosTriggered();
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); sl<TtsService>().speak('SOS terkirim ke Guardian.');
await _loadHistory(); _snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
} on DioException catch (e) { await _loadHistory();
final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS'; },
_snack(msg); onError: _snack,
} catch (e) { fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
_snack('Gagal kirim SOS: $e'); );
} finally { if (mounted) setState(() => _sending = false);
if (mounted) setState(() => _sending = false);
}
} }
// Build // Build

View File

@ -1,17 +1,14 @@
// ignore_for_file: use_build_context_synchronously, deprecated_member_use // ignore_for_file: use_build_context_synchronously, deprecated_member_use
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart'; import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../core/services/location_reporter_service.dart'; import '../../core/services/location_reporter_service.dart';
@ -36,6 +33,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
bool _processingFrame = false; bool _processingFrame = false;
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
void dispose() { void dispose() {
@ -59,7 +57,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
} }
setState(() { setState(() {
_active = next; _active = next;
_status = next ? 'Camera stream active. YOLO ready.' : 'Stopped'; _status = next ? _activeStatusText() : 'Stopped';
}); });
await sl<ApiClient>() await sl<ApiClient>()
.dio .dio
@ -67,13 +65,30 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
} }
String _activeStatusText() {
final detector = sl<YoloDetector>();
if (kIsWeb) {
return 'Camera preview aktif, tapi YOLO TFLite tidak jalan di Chrome/web. Test AI harus lewat APK Android.';
}
if (!detector.isReady) {
return detector.lastError == null
? 'YOLO model belum siap. Pastikan assets/models/yolov8n.tflite ada.'
: 'YOLO model error: ${detector.lastError}';
}
return 'Camera stream active. YOLO ready. Waiting for camera frames...';
}
Future<void> _startCamera() async { Future<void> _startCamera() async {
if (_camera != null) return; if (_camera != null) return;
try { try {
final cameras = await availableCameras(); final cameras = await availableCameras();
if (cameras.isEmpty) return; if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController( final controller = CameraController(
cameras.first, backCamera,
ResolutionPreset.medium, ResolutionPreset.medium,
enableAudio: false, enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420, imageFormatGroup: ImageFormatGroup.yuv420,
@ -86,11 +101,13 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
try { try {
await controller.startImageStream(_onCameraImage); await controller.startImageStream(_onCameraImage);
} catch (_) { } catch (_) {
// Preview still works; manual Demo Detect remains available. setState(() => _status = kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
} }
setState(() => _camera = controller); setState(() => _camera = controller);
} catch (_) { } catch (_) {
setState(() => _status = 'Camera unavailable. Demo mode active.'); setState(() => _status = 'Camera unavailable.');
} }
} }
@ -118,24 +135,28 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
} }
Future<void> _runYolo(CameraImage image) async { Future<void> _runYolo(CameraImage image) async {
final detection = await sl<YoloDetector>().detect(image); final detector = sl<YoloDetector>();
if (detection == null || !mounted) return; final detection = await detector.detect(image, confidenceThreshold: 0.25);
if (detection == null || !mounted) {
final now = DateTime.now();
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
_lastModelWarningAt = now;
setState(() => _status = detector.isReady
? 'Scanning... ${detector.diagnosticsSummary}'
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
}
return;
}
await _handleDetection(detection); await _handleDetection(detection);
} }
Future<void> _simulateObstacle() async {
final detection = await sl<YoloDetector>().detectFallback();
if (detection == null) return;
await _handleDetection(detection, forceAlert: true);
}
Future<void> _handleDetection( Future<void> _handleDetection(
DetectionResult detection, { DetectionResult detection, {
bool forceAlert = false, bool forceAlert = false,
}) async { }) async {
_lastDetection = detection; _lastDetection = detection;
setState(() => _status = setState(() => _status =
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}'); 'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
final now = DateTime.now(); final now = DateTime.now();
if (!forceAlert && if (!forceAlert &&
@ -187,6 +208,12 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
const Center( const Center(
child: Icon(Icons.videocam_outlined, child: Icon(Icons.videocam_outlined,
color: Colors.white30, size: 96)), color: Colors.white30, size: 96)),
if (_lastDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter: _DetectionOverlayPainter(_lastDetection!),
),
),
Positioned( Positioned(
top: 16, top: 16,
left: 16, left: 16,
@ -199,7 +226,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
left: 16, left: 16,
child: _Pill( child: _Pill(
text: text:
'${_lastDetection!.label} ${_lastDetection!.directionName}', '${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
color: Colors.redAccent), color: Colors.redAccent),
), ),
Positioned( Positioned(
@ -223,12 +250,6 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
onPressed: _toggle, onPressed: _toggle,
icon: Icon(_active ? Icons.stop : Icons.play_arrow), icon: Icon(_active ? Icons.stop : Icons.play_arrow),
label: Text(_active ? 'Stop' : 'Start'))), label: Text(_active ? 'Stop' : 'Start'))),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: _simulateObstacle,
icon: const Icon(Icons.radar),
label: const Text('Demo Detect'))),
], ],
), ),
], ],
@ -237,229 +258,74 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
} }
} }
// --------------------------------------------------------------------------- class _DetectionOverlayPainter extends CustomPainter {
// AiBenchmarkScreen final DetectionResult detection;
// --------------------------------------------------------------------------- const _DetectionOverlayPainter(this.detection);
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
@override @override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState(); void paint(Canvas canvas, Size size) {
} final box = detection.box;
if (box == null) return;
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> { final sx = size.width / ObstacleAnalyzer.frameWidth;
static const _runsKey = 'ai_benchmark_runs'; final sy = size.height / ObstacleAnalyzer.frameHeight;
List<String> _models = const []; final rect = Rect.fromLTRB(
String _selectedModel = AppConstants.yoloModelPath; box.left * sx,
List<Map<String, dynamic>> _runs = const []; box.top * sy,
bool _running = false; box.right * sx,
box.bottom * sy,
@override );
void initState() { final color = switch (detection.direction) {
super.initState(); ObstacleDirection.center => const Color(0xFFEF4444),
_load(); ObstacleDirection.left => const Color(0xFFF59E0B),
} ObstacleDirection.right => const Color(0xFFF59E0B),
Future<void> _load() async {
final models = await _discoverTfliteModels();
final selected = await AppConstants.getSelectedYoloModelPath();
final prefs = await SharedPreferences.getInstance();
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
setState(() {
_models = models.isEmpty ? [selected] : models;
_selectedModel = models.contains(selected) ? selected : _models.first;
_runs = rawRuns
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack(context, 'Model aktif: ${value.split('/').last}');
}
Future<void> _runBenchmark() async {
setState(() => _running = true);
final started = DateTime.now();
final captureMs = await _measureCapture();
final inferenceWatch = Stopwatch()..start();
String label = 'person';
String direction = 'CENTER';
String distance = 'Demo';
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
if (detection != null) {
label = detection.label;
direction = detection.directionName;
distance = detection.estimatedDistance;
}
inferenceWatch.stop();
final notifWatch = Stopwatch()..start();
final text = 'Obstacle $label di $direction, jarak $distance';
notifWatch.stop();
final ttsWatch = Stopwatch()..start();
try {
await sl<TtsService>()
.speakImmediate(text)
.timeout(const Duration(seconds: 3));
} catch (_) {}
ttsWatch.stop();
final run = {
'time': started.toIso8601String(),
'model': _selectedModel,
'modelLoaded': modelLoaded,
'captureMs': captureMs,
'inferenceMs': inferenceWatch.elapsedMilliseconds,
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
'ttsMs': ttsWatch.elapsedMilliseconds,
'label': label,
'direction': direction,
}; };
final prefs = await SharedPreferences.getInstance();
final next = [
jsonEncode(run),
...((prefs.getStringList(_runsKey) ?? const []).take(24))
];
await prefs.setStringList(_runsKey, next);
if (mounted) {
setState(() {
_runs = [run, ..._runs].take(25).toList();
_running = false;
});
}
}
Future<int> _measureCapture() async { canvas.drawRRect(
final watch = Stopwatch()..start(); RRect.fromRectAndRadius(rect, const Radius.circular(10)),
CameraController? controller; Paint()
try { ..color = color.withValues(alpha: 0.12)
final cameras = ..style = PaintingStyle.fill,
await availableCameras().timeout(const Duration(seconds: 3)); );
if (cameras.isNotEmpty) { canvas.drawRRect(
controller = CameraController(cameras.first, ResolutionPreset.low, RRect.fromRectAndRadius(rect, const Radius.circular(10)),
enableAudio: false); Paint()
await controller.initialize().timeout(const Duration(seconds: 5)); ..color = color
await controller.takePicture().timeout(const Duration(seconds: 5)); ..strokeWidth = 3
} ..style = PaintingStyle.stroke,
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _clearRuns() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_runsKey);
setState(() => _runs = const []);
}
@override
Widget build(BuildContext context) {
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
return _Page(
title: 'AI Benchmark',
subtitle: 'Capture, model, notification text, and TTS timing',
child: ListView(
children: [
DropdownButtonFormField<String>(
value: _selectedModel,
decoration: const InputDecoration(labelText: 'Model file'),
items: [
for (final model in _models)
DropdownMenuItem(
value: model, child: Text(model.split('/').last))
],
onChanged: _setModel,
),
if (!hasRealModel) ...[
const SizedBox(height: 10),
const _StatusBox(
success: false,
message:
'Belum ada file .tflite di assets/models. Taruh 3-5 model di folder itu, lalu restart app untuk muncul di dropdown.',
),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _running ? null : _runBenchmark,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _clearRuns,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear log')),
const SizedBox(height: 16),
for (final run in _runs) _BenchmarkCard(run: run),
if (_runs.isEmpty)
const _EmptyPanel(
icon: Icons.speed,
title: 'Belum Ada Log',
message:
'Klik Run benchmark untuk mencatat waktu capture, model/inference, text notification, dan TTS.',
),
],
),
); );
}
}
class _BenchmarkCard extends StatelessWidget { final label =
final Map<String, dynamic> run; '${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}';
const _BenchmarkCard({required this.run}); final textPainter = TextPainter(
text: TextSpan(
@override text: label,
Widget build(BuildContext context) { style: const TextStyle(
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal(); color: Colors.white,
return Card( fontSize: 13,
elevation: 0, fontWeight: FontWeight.w800,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time == null
? 'Benchmark run'
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
Text(
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'fallback'})'),
Text('Capture: ${run['captureMs']} ms'),
Text('Model/inference: ${run['inferenceMs']} ms'),
Text('Notification text: ${run['notificationMs']} ms'),
Text('TTS start: ${run['ttsMs']} ms'),
Text('Result: ${run['label']} ${run['direction']}'),
],
), ),
), ),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout(maxWidth: size.width - 24);
final labelRect = Rect.fromLTWH(
rect.left.clamp(8.0, size.width - textPainter.width - 16),
(rect.top - textPainter.height - 10).clamp(8.0, size.height - 32),
textPainter.width + 14,
textPainter.height + 8,
); );
canvas.drawRRect(
RRect.fromRectAndRadius(labelRect, const Radius.circular(8)),
Paint()..color = color.withValues(alpha: 0.92),
);
textPainter.paint(canvas, labelRect.topLeft + const Offset(7, 4));
}
@override
bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) {
return oldDelegate.detection != detection;
} }
} }
@ -533,88 +399,3 @@ class _Pill extends StatelessWidget {
); );
} }
} }
class _StatusBox extends StatelessWidget {
final bool success;
final String message;
const _StatusBox({required this.success, required this.message});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(message,
style: TextStyle(
color: success
? const Color(0xFF166534)
: const Color(0xFF991B1B))),
),
);
}
}
class _EmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _EmptyPanel(
{required this.icon, required this.title, required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 180),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0))),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: const Color(0xFF64748B), size: 48),
const SizedBox(height: 12),
Text(title,
style:
const TextStyle(fontWeight: FontWeight.w800, fontSize: 18)),
const SizedBox(height: 6),
Text(message, textAlign: TextAlign.center),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
} catch (_) {
return const [];
}
}
String _two(int value) => value.toString().padLeft(2, '0');
void _snack(BuildContext context, String message) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}

View File

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
class FeaturePage extends StatelessWidget {
final String title;
final String subtitle;
final Widget child;
final Widget? trailing;
const FeaturePage({
super.key,
required this.title,
required this.subtitle,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
);
}
}
class FeatureEmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final Widget? action;
const FeatureEmptyPanel({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: const Color(0xFF64748B)),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
),
if (action != null) ...[
const SizedBox(height: 16),
action!,
],
],
),
),
);
}
}
class FeatureErrorPanel extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const FeatureErrorPanel({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFECACA)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 34),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
],
),
),
);
}
}