Compare commits
No commits in common. "b8ad8df993425b2555b7a11f04525157aca4020e" and "f844ddddbb98fd7d40faa02e4ff1ffb1f4b2863c" have entirely different histories.
b8ad8df993
...
f844ddddbb
11
.gitignore
vendored
11
.gitignore
vendored
@ -46,14 +46,3 @@ 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
|
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,12 @@ 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.CallNotificationService;
|
import com.walkguide.service.FcmService;
|
||||||
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;
|
||||||
@ -14,13 +17,23 @@ 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.PostMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
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
|
||||||
@ -30,10 +43,17 @@ import java.util.Map;
|
|||||||
public class CallController {
|
public class CallController {
|
||||||
|
|
||||||
private final AgoraTokenService agoraTokenService;
|
private final AgoraTokenService agoraTokenService;
|
||||||
private final CallNotificationService callNotificationService;
|
private final FcmService fcmService;
|
||||||
|
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 requests a token before joining Agora")
|
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
|
||||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||||
@Valid @RequestBody CallTokenRequest req) {
|
@Valid @RequestBody CallTokenRequest req) {
|
||||||
|
|
||||||
@ -46,16 +66,64 @@ 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);
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
// Ambil info caller untuk notifikasi
|
||||||
|
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(
|
||||||
@ -63,7 +131,19 @@ public class CallController {
|
|||||||
|
|
||||||
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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.getLocationHistoryForGuardian(guardianId,
|
locationService.getLocationHistory(guardianId,
|
||||||
PageRequest.of(page, size, Sort.by("createdAt").descending())),
|
PageRequest.of(page, size, Sort.by("createdAt").descending())),
|
||||||
"Riwayat lokasi"));
|
"Riwayat lokasi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,9 @@ 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.*;
|
||||||
@ -18,26 +16,12 @@ 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(
|
||||||
@Valid @RequestBody InviteUserRequest req) {
|
@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.resolveSubmittedCode()),
|
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
|
||||||
"Undangan dikirim ke user"));
|
"Undangan dikirim ke user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,16 +5,7 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -12,8 +12,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,12 +29,6 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,5 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
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))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ 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;
|
||||||
@ -80,15 +79,6 @@ 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) {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@ -11,9 +10,7 @@ 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;
|
||||||
|
|
||||||
@ -29,46 +26,8 @@ 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 PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
|
||||||
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.");
|
||||||
@ -79,7 +38,8 @@ 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 = resolveUserByPairingCode(submittedCode);
|
User user = userRepository.findByUniqueUserId(uniqueUserId)
|
||||||
|
.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.");
|
||||||
@ -95,10 +55,6 @@ 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",
|
||||||
@ -243,51 +199,6 @@ 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();
|
||||||
@ -300,8 +211,6 @@ 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();
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL}
|
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
username: ${DB_USERNAME}
|
username: ${DB_USERNAME:5803024001}
|
||||||
password: ${DB_PASSWORD}
|
password: ${DB_PASSWORD:pw5803024001}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
@ -17,8 +17,8 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
expiration: 86400000
|
||||||
|
|
||||||
agora:
|
agora:
|
||||||
app-id: ${AGORA_APP_ID:}
|
app-id: ${AGORA_APP_ID:}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@ -4,8 +4,7 @@ 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: https://api.walkguide.example/api/v1
|
- url: http://localhost:8080/api/v1
|
||||||
description: Production deployment URL placeholder
|
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
components:
|
components:
|
||||||
@ -38,16 +37,9 @@ components:
|
|||||||
role: { type: string, enum: [USER, GUARDIAN] }
|
role: { type: string, enum: [USER, GUARDIAN] }
|
||||||
PairingInviteRequest:
|
PairingInviteRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [pairingCode]
|
required: [uniqueUserId]
|
||||||
properties:
|
properties:
|
||||||
pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
|
uniqueUserId: { type: string, minLength: 12, maxLength: 12 }
|
||||||
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]
|
||||||
@ -115,14 +107,6 @@ 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:
|
||||||
|
|||||||
@ -4,10 +4,13 @@ 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.security.JwtAuthFilter;
|
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.CallNotificationService;
|
import com.walkguide.service.FcmService;
|
||||||
|
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;
|
||||||
@ -15,22 +18,19 @@ 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.any;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.Mockito.*;
|
||||||
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.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
@AutoConfigureMockMvc(addFilters = false)
|
@AutoConfigureMockMvc(addFilters = false)
|
||||||
@WebMvcTest(CallController.class)
|
@WebMvcTest(CallController.class)
|
||||||
@ -38,8 +38,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@DisplayName("CallController Unit Tests")
|
@DisplayName("CallController Unit Tests")
|
||||||
class CallControllerTest {
|
class CallControllerTest {
|
||||||
|
|
||||||
@MockBean
|
@MockBean private JwtAuthFilter jwtAuthFilter;
|
||||||
private JwtAuthFilter jwtAuthFilter;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
@ -51,10 +50,35 @@ class CallControllerTest {
|
|||||||
private AgoraTokenService agoraTokenService;
|
private AgoraTokenService agoraTokenService;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private CallNotificationService callNotificationService;
|
private FcmService fcmService;
|
||||||
|
|
||||||
|
@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 returns Agora token")
|
@DisplayName("POST /api/v1/shared/call/token - valid request harus return 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);
|
||||||
@ -64,7 +88,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")
|
.channelName("call_1_2_1234567890")
|
||||||
.uid(1001)
|
.uid(1001)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -78,24 +102,34 @@ 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"));
|
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890"));
|
||||||
|
|
||||||
|
verify(agoraTokenService).generateToken(1L, 2L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
|
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400")
|
||||||
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
|
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
|
||||||
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
|
|
||||||
|
// CallTokenRequest dengan receiverId null — @Valid harus menolak
|
||||||
CallTokenRequest req = new CallTokenRequest();
|
CallTokenRequest req = new CallTokenRequest();
|
||||||
|
// receiverId tidak di-set (null)
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/token")
|
mockMvc.perform(post("/api/v1/shared/call/token")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/token - service error returns 500")
|
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 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);
|
||||||
@ -114,17 +148,21 @@ class CallControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== NOTIFY CALL =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
|
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi")
|
||||||
void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
|
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() 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)))
|
|
||||||
.thenReturn("Notifikasi panggilan berhasil dikirim");
|
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
||||||
|
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");
|
req.setChannelName("call_1_2_1234567890");
|
||||||
req.setAgoraToken("agora-token-xyz");
|
req.setAgoraToken("agora-token-xyz");
|
||||||
req.setReceiverUid(1002);
|
req.setReceiverUid(1002);
|
||||||
|
|
||||||
@ -136,40 +174,260 @@ 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(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
|
verify(fcmService).sendHighPriority(
|
||||||
|
eq("fcm-receiver-token"),
|
||||||
|
eq("📞 Panggilan Masuk"),
|
||||||
|
contains("Caller User"),
|
||||||
|
anyMap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
|
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM")
|
||||||
void notifyCall_invalidRequest_shouldReturn400() throws Exception {
|
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception {
|
||||||
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
|
|
||||||
|
User receiverNoFcm = User.builder()
|
||||||
|
.id(2L)
|
||||||
|
.email("receiver@test.com")
|
||||||
|
.displayName("Receiver")
|
||||||
|
.fcmToken(null) // tidak punya FCM token
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
CallNotifyRequest req = new CallNotifyRequest();
|
||||||
req.setChannelName("call_1_2");
|
req.setReceiverId(2L);
|
||||||
|
req.setChannelName("call_1_2_abc");
|
||||||
|
req.setAgoraToken("token-xyz");
|
||||||
|
req.setReceiverUid(1002);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
mockMvc.perform(post("/api/v1/shared/call/notify")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").value(
|
||||||
|
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
|
||||||
|
|
||||||
verify(callNotificationService, never()).notifyIncomingCall(any(), any());
|
// FCM tidak boleh dipanggil karena tidak ada token
|
||||||
|
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
|
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM")
|
||||||
void endCall_validOtherId_shouldDelegateToService() throws Exception {
|
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() 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(Map.of("otherId", 2L))))
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
.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(callNotificationService).notifyCallEnded(1L, 2L);
|
verify(fcmService).sendToToken(
|
||||||
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.setPairingCode("BAD99999");
|
req.setUniqueUserId("INVALID999");
|
||||||
|
|
||||||
when(pairingService.inviteUser(2L, "BAD99999"))
|
when(pairingService.inviteUser(2L, "INVALID999"))
|
||||||
.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")
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ 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;
|
||||||
@ -233,35 +232,6 @@ 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
|
||||||
|
|||||||
@ -16,7 +16,6 @@ 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.*;
|
||||||
@ -55,8 +54,6 @@ 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();
|
||||||
}
|
}
|
||||||
@ -69,7 +66,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.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
|
when(userRepository.findByUniqueUserId("ABC123DEF456")).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);
|
||||||
@ -77,7 +74,7 @@ class PairingServiceTest {
|
|||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
|
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456");
|
||||||
|
|
||||||
assertThat(result).isNotNull();
|
assertThat(result).isNotNull();
|
||||||
verify(pairingRelationRepository).save(any(PairingRelation.class));
|
verify(pairingRelationRepository).save(any(PairingRelation.class));
|
||||||
@ -116,7 +113,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.findByPairingCode("INVALID")).thenReturn(Optional.empty());
|
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class)
|
.isInstanceOf(ResourceNotFoundException.class)
|
||||||
@ -127,17 +124,14 @@ 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")
|
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build();
|
||||||
.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.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
|
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian));
|
||||||
|
|
||||||
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001"))
|
||||||
.isInstanceOf(PairingException.class)
|
.isInstanceOf(PairingException.class)
|
||||||
.hasMessageContaining("bukan milik User");
|
.hasMessageContaining("bukan milik User");
|
||||||
}
|
}
|
||||||
@ -148,10 +142,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.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
|
when(userRepository.findByUniqueUserId("ABC123DEF456")).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, "AB12CD34"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456"))
|
||||||
.isInstanceOf(PairingException.class)
|
.isInstanceOf(PairingException.class)
|
||||||
.hasMessageContaining("sudah dipair dengan Guardian lain");
|
.hasMessageContaining("sudah dipair dengan Guardian lain");
|
||||||
}
|
}
|
||||||
|
|||||||
10
walkguide-mobile/walkguide_app/.gitignore
vendored
10
walkguide-mobile/walkguide_app/.gitignore
vendored
@ -49,13 +49,3 @@ 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/
|
|
||||||
|
|||||||
@ -31,10 +31,6 @@ 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.
|
||||||
|
|||||||
@ -1,80 +1,10 @@
|
|||||||
person
|
person
|
||||||
bicycle
|
|
||||||
car
|
car
|
||||||
motorcycle
|
motorcycle
|
||||||
airplane
|
bicycle
|
||||||
bus
|
bus
|
||||||
train
|
|
||||||
truck
|
truck
|
||||||
boat
|
|
||||||
traffic light
|
|
||||||
fire hydrant
|
|
||||||
stop sign
|
|
||||||
parking meter
|
|
||||||
bench
|
|
||||||
bird
|
|
||||||
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
|
chair
|
||||||
couch
|
bench
|
||||||
potted plant
|
door
|
||||||
bed
|
stairs
|
||||||
dining table
|
|
||||||
toilet
|
|
||||||
tv
|
|
||||||
laptop
|
|
||||||
mouse
|
|
||||||
remote
|
|
||||||
keyboard
|
|
||||||
cell phone
|
|
||||||
microwave
|
|
||||||
oven
|
|
||||||
toaster
|
|
||||||
sink
|
|
||||||
refrigerator
|
|
||||||
book
|
|
||||||
clock
|
|
||||||
vase
|
|
||||||
scissors
|
|
||||||
teddy bear
|
|
||||||
hair drier
|
|
||||||
toothbrush
|
|
||||||
|
|||||||
@ -20,79 +20,35 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
routerConfig: appRouter,
|
routerConfig: appRouter,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(seedColor: seed),
|
||||||
seedColor: seed,
|
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
|
|
||||||
textTheme: GoogleFonts.interTextTheme(),
|
textTheme: GoogleFonts.interTextTheme(),
|
||||||
pageTransitionsTheme: const PageTransitionsTheme(
|
|
||||||
builders: {
|
|
||||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
|
||||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
|
||||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: Color(0xFFF4F7FB),
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: Color(0xFF0F172A),
|
foregroundColor: Color(0xFF0F172A),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.white,
|
||||||
),
|
|
||||||
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(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: seed,
|
backgroundColor: seed,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(0, 50),
|
minimumSize: const Size(0, 46),
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
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(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: Colors.white,
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,33 +2,8 @@ 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/pairing/pairing_screens.dart' as pairing;
|
import '../features/screens.dart';
|
||||||
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(
|
||||||
@ -50,23 +25,19 @@ final GoRouter appRouter = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/server-connect',
|
path: '/server-connect',
|
||||||
builder: (_, __) => const server_connect.ServerConnectScreen()),
|
builder: (_, __) => const ServerConnectScreen()),
|
||||||
|
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||||
|
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||||
|
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
|
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()),
|
||||||
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 walk_guide.WalkGuideScreen()),
|
builder: (_, __) => const WalkGuideScreen()),
|
||||||
GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
|
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/activity',
|
path: '/user/activity',
|
||||||
builder: (_, __) => const activity.ActivityLogScreen()),
|
builder: (_, __) => const activity.ActivityLogScreen()),
|
||||||
@ -75,18 +46,17 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (_, __) => const notifications.NotificationScreen()),
|
builder: (_, __) => const notifications.NotificationScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/navigation',
|
path: '/user/navigation',
|
||||||
builder: (_, __) => const nav.NavigationModeScreen()),
|
builder: (_, __) => const NavigationModeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/settings',
|
path: '/user/settings',
|
||||||
builder: (_, __) => const user_settings.UserSettingsScreen()),
|
builder: (_, __) => const UserSettingsScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/pairing',
|
path: '/user/pairing',
|
||||||
builder: (_, __) => const pairing.UserPairingScreen()),
|
builder: (_, __) => const UserPairingScreen()),
|
||||||
GoRoute(
|
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()),
|
||||||
path: '/user/call', builder: (_, __) => const call.CallScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/benchmark',
|
path: '/user/benchmark',
|
||||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
builder: (_, __) => const AiBenchmarkScreen()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@ -94,39 +64,37 @@ final GoRouter appRouter = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/dashboard',
|
path: '/guardian/dashboard',
|
||||||
builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
|
builder: (_, __) => const GuardianDashboardScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/map',
|
path: '/guardian/map',
|
||||||
builder: (_, __) => const guardian_map.GuardianMapScreen()),
|
builder: (_, __) => const GuardianMapScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/logs',
|
path: '/guardian/logs',
|
||||||
builder: (_, __) =>
|
builder: (_, __) => const GuardianActivityLogScreen()),
|
||||||
const guardian_logs.GuardianActivityLogScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/send-notif',
|
path: '/guardian/send-notif',
|
||||||
builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
|
builder: (_, __) => const GuardianSendNotifScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/ai-config',
|
path: '/guardian/ai-config',
|
||||||
builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
|
builder: (_, __) => const GuardianAiConfigScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/voice-cmd',
|
path: '/guardian/voice-cmd',
|
||||||
builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
|
builder: (_, __) => const GuardianVoiceCmdScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/shortcuts',
|
path: '/guardian/shortcuts',
|
||||||
builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
|
builder: (_, __) => const GuardianShortcutScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/geofence',
|
path: '/guardian/geofence',
|
||||||
builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
|
builder: (_, __) => const GuardianGeofenceScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/pairing',
|
path: '/guardian/pairing',
|
||||||
builder: (_, __) => const pairing.GuardianPairingScreen()),
|
builder: (_, __) => const GuardianPairingScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/settings',
|
path: '/guardian/settings',
|
||||||
builder: (_, __) =>
|
builder: (_, __) => const GuardianSettingsScreen()),
|
||||||
const guardian_settings.GuardianSettingsScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/benchmark',
|
path: '/guardian/benchmark',
|
||||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
builder: (_, __) => const AiBenchmarkScreen()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -23,14 +23,12 @@ 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();
|
||||||
@ -41,7 +39,7 @@ class DetectionResult {
|
|||||||
ObstacleDirection.center => 'tengah',
|
ObstacleDirection.center => 'tengah',
|
||||||
ObstacleDirection.right => 'kanan',
|
ObstacleDirection.right => 'kanan',
|
||||||
};
|
};
|
||||||
return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
|
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +68,7 @@ class ObstacleAnalyzer {
|
|||||||
ObstacleDirection.center => 'depan',
|
ObstacleDirection.center => 'depan',
|
||||||
ObstacleDirection.right => 'kanan',
|
ObstacleDirection.right => 'kanan',
|
||||||
};
|
};
|
||||||
return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
|
return 'Hati-hati, ${result.label} di $directionLabel. '
|
||||||
'Jarak ${result.estimatedDistance}.';
|
'Jarak ${result.estimatedDistance}.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,12 +90,7 @@ 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;
|
||||||
final distanceCompare = aRank.compareTo(bRank);
|
return 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;
|
||||||
}
|
}
|
||||||
@ -109,43 +102,15 @@ class ObstacleAnalyzer {
|
|||||||
return detections.where((d) => d.confidence >= threshold).toList();
|
return detections.where((d) => d.confidence >= threshold).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
int _directionRisk(ObstacleDirection direction) {
|
DetectionResult analyzeFallback({
|
||||||
return switch (direction) {
|
String label = 'person',
|
||||||
ObstacleDirection.center => 0,
|
double confidence = 0.86,
|
||||||
ObstacleDirection.left => 1,
|
}) {
|
||||||
ObstacleDirection.right => 1,
|
return DetectionResult(
|
||||||
};
|
label: label,
|
||||||
}
|
confidence: confidence,
|
||||||
|
direction: ObstacleDirection.center,
|
||||||
static String spokenLabel(String label) {
|
estimatedDistance: 'Close (1-2m)',
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,31 +18,11 @@ 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();
|
||||||
@ -67,64 +47,55 @@ class YoloDetector {
|
|||||||
_ready = true;
|
_ready = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_lastError = e.toString();
|
_lastError = e.toString();
|
||||||
debugPrint('YOLO runtime initialization skipped: $e');
|
debugPrint('YOLO fallback mode: $e');
|
||||||
_ready = false;
|
_ready = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DetectionResult?> detect(
|
Future<DetectionResult?> detect(
|
||||||
CameraImage image, {
|
CameraImage image, {
|
||||||
double confidenceThreshold = 0.25,
|
double confidenceThreshold = 0.45,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isReady) return null;
|
if (!isReady) return detectFallback();
|
||||||
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);
|
||||||
final kept = _nonMaxSuppression(filtered);
|
return _analyzer.prioritize(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 skipped: $e');
|
debugPrint('YOLO inference fallback: $e');
|
||||||
return null;
|
return detectFallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DetectionResult?> detectSynthetic({
|
Future<DetectionResult?> detectSynthetic({
|
||||||
double confidenceThreshold = 0.25,
|
double confidenceThreshold = 0.25,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isReady) return null;
|
if (!isReady) return detectFallback();
|
||||||
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);
|
||||||
final kept = _nonMaxSuppression(filtered);
|
return _analyzer.prioritize(filtered) ?? detectFallback();
|
||||||
_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 skipped: $e');
|
debugPrint('YOLO synthetic fallback: $e');
|
||||||
return null;
|
return detectFallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@ -133,26 +104,6 @@ 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) {
|
||||||
@ -341,7 +292,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;
|
||||||
// Keep preprocessing explicit if an unsupported variant is selected.
|
// fallback keeps the app usable 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');
|
||||||
@ -533,164 +484,19 @@ 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 {
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart'; // Wajib import ini
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const baseUrl = String.fromEnvironment(
|
static String get baseUrl {
|
||||||
'WALKGUIDE_API_BASE_URL',
|
if (kIsWeb) {
|
||||||
defaultValue: 'http://202.46.28.160:8080/api/v1',
|
// Jika di Chrome/Web, tembak localhost langsung
|
||||||
);
|
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',
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 untuk membuka channel realtime.
|
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices).
|
||||||
Future<void> connect(String serverUrl) async {
|
Future<void> connect(String serverUrl) async {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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';
|
||||||
|
|
||||||
@ -45,8 +44,7 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/user/activity-logs')
|
.get('/user/activity-logs')
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@ -56,12 +54,17 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
_items = items;
|
_items = items;
|
||||||
_applyFilter(_selectedFilter);
|
_applyFilter(_selectedFilter);
|
||||||
});
|
});
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => setState(() => _error = message),
|
setState(() {
|
||||||
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
|
_error = e.response?.data?['message']?.toString() ??
|
||||||
);
|
'Gagal memuat activity log.';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = 'Timeout / error: $e');
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||||
final data = responseBody is Map ? responseBody['data'] : null;
|
final data = responseBody is Map ? responseBody['data'] : null;
|
||||||
|
|||||||
@ -1,290 +0,0 @@
|
|||||||
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');
|
|
||||||
@ -12,8 +12,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
|
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Either<Failure, UserEntity>> login(
|
Future<Either<Failure, UserEntity>> login(String email, String password) async {
|
||||||
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);
|
||||||
@ -25,21 +24,7 @@ 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(_safeAuthFailure(e)));
|
return Left(ServerFailure(e.toString().replaceAll('Exception: ', '')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -10,7 +11,6 @@ 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);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
|
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
|
||||||
'email': _email.text.trim(),
|
'email': _email.text.trim(),
|
||||||
'password': _password.text,
|
'password': _password.text,
|
||||||
});
|
});
|
||||||
await _saveAuthAndRoute(
|
await _saveAuthAndRoute(
|
||||||
context, Map<String, dynamic>.from(res.data['data'] as Map));
|
context, Map<String, dynamic>.from(res.data['data'] as Map));
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => _snack(context, message),
|
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal'));
|
||||||
fallback: 'Login gagal. Periksa email dan password kamu.',
|
} catch (e) {
|
||||||
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
_snack(context, 'Login gagal: $e');
|
||||||
);
|
} finally {
|
||||||
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 ke navigasi asistif realtime WalkGuide.',
|
subtitle: 'Masuk sebagai Guardian atau User.',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@ -147,123 +147,35 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFEAF4FF),
|
body: Center(
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
const Positioned.fill(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 430),
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: Card(
|
||||||
tween: Tween(begin: 18, end: 0),
|
elevation: 0,
|
||||||
duration: const Duration(milliseconds: 520),
|
shape: RoundedRectangleBorder(
|
||||||
curve: Curves.easeOutCubic,
|
borderRadius: BorderRadius.circular(18),
|
||||||
builder: (_, offset, child) => Opacity(
|
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
||||||
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Icon(Icons.navigation_rounded,
|
||||||
children: [
|
color: Color(0xFF1A56DB), size: 42),
|
||||||
Container(
|
const SizedBox(height: 14),
|
||||||
width: 56,
|
Text(title,
|
||||||
height: 56,
|
textAlign: TextAlign.center,
|
||||||
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)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineMedium
|
.headlineSmall
|
||||||
?.copyWith(
|
?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
fontWeight: FontWeight.w900,
|
const SizedBox(height: 4),
|
||||||
color: const Color(0xFF0F172A),
|
Text(subtitle,
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
),
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 22),
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFF64748B),
|
|
||||||
height: 1.35,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 26),
|
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -272,10 +184,6 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -311,14 +219,16 @@ 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');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,3 +238,19 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export '../login_screen.dart';
|
export '../../screens.dart';
|
||||||
|
|||||||
@ -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,8 +52,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
|
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
|
||||||
'displayName': _name.text.trim(),
|
'displayName': _name.text.trim(),
|
||||||
'email': _email.text.trim(),
|
'email': _email.text.trim(),
|
||||||
@ -66,13 +65,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
await prefs.setString('pending_login_email', _email.text.trim());
|
await prefs.setString('pending_login_email', _email.text.trim());
|
||||||
await _showRegisterSuccess(context, data);
|
await _showRegisterSuccess(context, data);
|
||||||
if (mounted) context.go('/login');
|
if (mounted) context.go('/login');
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => _snack(context, message),
|
_snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal'));
|
||||||
fallback: 'Registrasi gagal. Periksa data akun kamu.',
|
} catch (e) {
|
||||||
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
_snack(context, 'Registrasi gagal: $e');
|
||||||
);
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -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 bisa membuat Pairing Code sementara setelah login.'
|
? 'User akan mendapat Unique ID untuk pairing.'
|
||||||
: '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,123 +298,35 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFEAF4FF),
|
body: Center(
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
const Positioned.fill(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 430),
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: Card(
|
||||||
tween: Tween(begin: 18, end: 0),
|
elevation: 0,
|
||||||
duration: const Duration(milliseconds: 520),
|
shape: RoundedRectangleBorder(
|
||||||
curve: Curves.easeOutCubic,
|
borderRadius: BorderRadius.circular(18),
|
||||||
builder: (_, offset, child) => Opacity(
|
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
||||||
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Icon(Icons.navigation_rounded,
|
||||||
children: [
|
color: Color(0xFF1A56DB), size: 42),
|
||||||
Container(
|
const SizedBox(height: 14),
|
||||||
width: 56,
|
Text(title,
|
||||||
height: 56,
|
textAlign: TextAlign.center,
|
||||||
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)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineMedium
|
.headlineSmall
|
||||||
?.copyWith(
|
?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
fontWeight: FontWeight.w900,
|
const SizedBox(height: 4),
|
||||||
color: const Color(0xFF0F172A),
|
Text(subtitle,
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
),
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 22),
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFF64748B),
|
|
||||||
height: 1.35,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 26),
|
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -423,10 +335,6 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -440,12 +348,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\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
|
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.';
|
||||||
_snack(
|
_snack(
|
||||||
context,
|
context,
|
||||||
uniqueId == null
|
uniqueId == null
|
||||||
? 'Registrasi berhasil.'
|
? 'Registrasi berhasil.'
|
||||||
: 'Registrasi berhasil. Buat Pairing Code setelah login.');
|
: 'Registrasi berhasil. ID: $uniqueId');
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@ -467,3 +375,19 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -52,8 +51,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _route() async {
|
Future<void> _route() async {
|
||||||
final routed = await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
@ -71,14 +69,11 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-login: arahkan ke home sesuai role.
|
// Auto-login: arahkan ke home sesuai role.
|
||||||
context.go(role == 'ROLE_GUARDIAN'
|
context.go(
|
||||||
? '/guardian/dashboard'
|
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide');
|
||||||
: '/user/walkguide');
|
} catch (_) {
|
||||||
},
|
if (mounted) context.go('/login');
|
||||||
onError: (_) {},
|
}
|
||||||
fallback: 'Sesi belum bisa dipulihkan.',
|
|
||||||
);
|
|
||||||
if (!routed && mounted) context.go('/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -7,7 +7,6 @@ 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;
|
||||||
@ -49,8 +48,7 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_needsPairing = false;
|
_needsPairing = false;
|
||||||
});
|
});
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
// Cek pairing dulu
|
// Cek pairing dulu
|
||||||
final paired = await _hasActivePairing();
|
final paired = await _hasActivePairing();
|
||||||
if (!paired) {
|
if (!paired) {
|
||||||
@ -87,29 +85,29 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
_applyFilter(_selectedFilter);
|
_applyFilter(_selectedFilter);
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => setState(() {
|
setState(() {
|
||||||
_error = message;
|
_error = e.response?.data?['message']?.toString() ??
|
||||||
|
'Gagal memuat activity log.';
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}),
|
});
|
||||||
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
|
} catch (e) {
|
||||||
);
|
setState(() {
|
||||||
|
_error = 'Timeout / error: $e';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _hasActivePairing() async {
|
Future<bool> _hasActivePairing() async {
|
||||||
final active = await runFriendly<bool>(
|
try {
|
||||||
() async {
|
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/shared/pairing/status')
|
.get('/shared/pairing/status')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) return data['status'] == 'ACTIVE';
|
if (data is Map) return data['status'] == 'ACTIVE';
|
||||||
|
} catch (_) {}
|
||||||
return false;
|
return false;
|
||||||
},
|
|
||||||
onError: (_) {},
|
|
||||||
fallback: 'Status pairing belum bisa dicek.',
|
|
||||||
);
|
|
||||||
return active ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilter(String filter) {
|
void _applyFilter(String filter) {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ 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;
|
||||||
@ -74,12 +73,11 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error =
|
_error = e.response?.data?['message']?.toString() ??
|
||||||
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
|
'Gagal memuat konfigurasi AI.';
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(
|
setState(() => _error = 'Timeout / error: $e');
|
||||||
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
@ -107,8 +105,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(friendlyDioMessage(e,
|
content: Text(e.response?.data?['message']?.toString() ??
|
||||||
fallback: 'Gagal menyimpan konfigurasi.')),
|
'Gagal menyimpan konfigurasi.'),
|
||||||
backgroundColor: const Color(0xFFDC2626),
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -116,9 +114,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
|
content: Text('Error: $e'),
|
||||||
backgroundColor: Color(0xFFDC2626),
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,301 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,15 +1,18 @@
|
|||||||
export '../home/presentation/guardian_dashboard_screen.dart'
|
export '../home/presentation/guardian_dashboard_screen.dart'
|
||||||
show GuardianDashboardScreen;
|
show GuardianDashboardScreen;
|
||||||
|
|
||||||
export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
|
export 'guardian_activity_log_screen.dart'
|
||||||
|
show
|
||||||
|
GuardianActivityLogScreen;
|
||||||
|
|
||||||
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
|
export 'guardian_ai_config_screen.dart'
|
||||||
|
show
|
||||||
|
GuardianAiConfigScreen;
|
||||||
|
|
||||||
export 'guardian_map_screen.dart' show GuardianMapScreen;
|
export '../screens.dart'
|
||||||
|
show
|
||||||
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
|
GuardianMapScreen,
|
||||||
|
GuardianSendNotifScreen,
|
||||||
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
|
GuardianVoiceCmdScreen,
|
||||||
|
GuardianShortcutScreen,
|
||||||
export 'guardian_tools_screen.dart'
|
GuardianGeofenceScreen;
|
||||||
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;
|
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@ 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';
|
||||||
|
|
||||||
@ -39,14 +38,12 @@ class _Step {
|
|||||||
required this.point});
|
required this.point});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation state is kept in a lightweight Cubit so the app uses one
|
// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate
|
||||||
// state-management family consistently.
|
// while staying consistent with the rest of screens.dart approach) ─────
|
||||||
|
|
||||||
enum _NavPhase { idle, locating, routing, navigating, error }
|
enum _NavPhase { idle, locating, routing, navigating, error }
|
||||||
|
|
||||||
class _NavState extends Cubit<int> {
|
class _NavState extends ChangeNotifier {
|
||||||
_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;
|
||||||
@ -62,11 +59,9 @@ class _NavState extends Cubit<int> {
|
|||||||
void _set(_NavPhase p, String status) {
|
void _set(_NavPhase p, String status) {
|
||||||
phase = p;
|
phase = p;
|
||||||
statusText = status;
|
statusText = status;
|
||||||
_notify();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
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…');
|
||||||
@ -90,8 +85,7 @@ class _NavState extends Cubit<int> {
|
|||||||
_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,
|
_set(_NavPhase.error, 'GPS error: $e');
|
||||||
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,11 +211,10 @@ class _NavState extends Cubit<int> {
|
|||||||
|
|
||||||
_set(_NavPhase.navigating,
|
_set(_NavPhase.navigating,
|
||||||
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
||||||
_notify();
|
notifyListeners();
|
||||||
_startTracking();
|
_startTracking();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_set(_NavPhase.error,
|
_set(_NavPhase.error, 'Gagal mendapat rute: $e');
|
||||||
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,7 +283,7 @@ class _NavState extends Cubit<int> {
|
|||||||
currentPosition = LatLng(pos.latitude, pos.longitude);
|
currentPosition = LatLng(pos.latitude, pos.longitude);
|
||||||
_reportToBackend(pos);
|
_reportToBackend(pos);
|
||||||
_updateStep();
|
_updateStep();
|
||||||
_notify();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,7 +302,7 @@ class _NavState extends Cubit<int> {
|
|||||||
final next = steps[currentStepIndex];
|
final next = steps[currentStepIndex];
|
||||||
statusText = next.instruction;
|
statusText = next.instruction;
|
||||||
sl<TtsService>().speak(next.instruction);
|
sl<TtsService>().speak(next.instruction);
|
||||||
_notify();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -325,9 +318,9 @@ class _NavState extends Cubit<int> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() {
|
void dispose() {
|
||||||
_posStream?.cancel();
|
_posStream?.cancel();
|
||||||
return super.close();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,7 +339,6 @@ 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;
|
||||||
@ -356,7 +348,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_navSubscription = _navState.stream.listen((_) => _onStateChange());
|
_navState.addListener(_onStateChange);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,8 +433,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_navSubscription?.cancel();
|
_navState.removeListener(_onStateChange);
|
||||||
_navState.close();
|
_navState.dispose();
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
_searchFocus.dispose();
|
_searchFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@ -6,7 +6,6 @@ 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';
|
||||||
@ -37,8 +36,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/user/notifications')
|
.get('/user/notifications')
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@ -46,12 +44,17 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
||||||
});
|
});
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => setState(() => _error = message),
|
setState(() {
|
||||||
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
|
_error = e.response?.data?['message']?.toString() ??
|
||||||
);
|
'Gagal memuat notifikasi.';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = 'Timeout / error: $e');
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||||
final data = responseBody is Map ? responseBody['data'] : null;
|
final data = responseBody is Map ? responseBody['data'] : null;
|
||||||
@ -64,8 +67,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markRead(int id) async {
|
Future<void> _markRead(int id) async {
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
await _api
|
await _api
|
||||||
.put('/user/notifications/$id/read')
|
.put('/user/notifications/$id/read')
|
||||||
.timeout(const Duration(seconds: 6));
|
.timeout(const Duration(seconds: 6));
|
||||||
@ -73,16 +75,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
final idx = _items.indexWhere((n) => n.id == id);
|
final idx = _items.indexWhere((n) => n.id == id);
|
||||||
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
|
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);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
await _api
|
await _api
|
||||||
.put('/user/notifications/mark-all-read')
|
.put('/user/notifications/mark-all-read')
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
@ -90,12 +88,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
||||||
});
|
});
|
||||||
_snack('Semua notifikasi ditandai sudah dibaca.');
|
_snack('Semua notifikasi ditandai sudah dibaca.');
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: _snack,
|
_snack(e.response?.data?['message']?.toString() ??
|
||||||
fallback: 'Gagal menandai semua dibaca.',
|
'Gagal menandai semua dibaca.');
|
||||||
);
|
} catch (_) {
|
||||||
|
_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 {
|
||||||
final tts = sl<TtsService>();
|
final tts = sl<TtsService>();
|
||||||
|
|||||||
@ -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 pairing code sementara yang bisa di-copy/share.
|
// - Tampilkan uniqueUserId mereka (besar, 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,10 +28,6 @@ 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() {
|
||||||
@ -42,87 +38,36 @@ 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) {
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await sl<ApiClient>()
|
final res = await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
.get('/user/profile')
|
.get('/user/profile')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) value = data['uniqueUserId']?.toString();
|
if (data is Map) value = data['uniqueUserId']?.toString();
|
||||||
},
|
} catch (_) {}
|
||||||
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 pairing code sementara ini ke Guardian.',
|
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (_pairingCode == null || _pairingCode!.isEmpty)
|
if (_uniqueId == null || _uniqueId!.isEmpty)
|
||||||
_InfoCard(
|
_InfoCard(
|
||||||
title: 'Pairing Code',
|
title: 'Your Unique ID',
|
||||||
value: 'Tap Generate',
|
value: 'Login sebagai User untuk melihat ID',
|
||||||
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: 'Pairing Code',
|
title: 'Your Unique ID',
|
||||||
value: _pairingCode!,
|
value: _uniqueId!,
|
||||||
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),
|
||||||
],
|
],
|
||||||
@ -136,7 +81,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Ditampilkan ke akun ROLE_GUARDIAN.
|
// Ditampilkan ke akun ROLE_GUARDIAN.
|
||||||
// - Input field 8-char temporary pairing code.
|
// - Input field 12-char User ID.
|
||||||
// - 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.
|
||||||
@ -155,46 +100,51 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
|
|||||||
int _statusReload = 0;
|
int _statusReload = 0;
|
||||||
|
|
||||||
Future<void> _invite() async {
|
Future<void> _invite() async {
|
||||||
final pairingCode = _id.text.trim().toUpperCase();
|
final uniqueId = _id.text.trim().toUpperCase();
|
||||||
if (pairingCode.isEmpty || pairingCode.length != 8) {
|
if (uniqueId.isEmpty || uniqueId.length != 12) {
|
||||||
_snack(context, 'Pairing code harus 8 karakter dari akun User.');
|
_snack(context, 'Unique ID harus 12 karakter dari akun User.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
|
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
|
||||||
data: {
|
data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8));
|
||||||
'pairingCode': pairingCode
|
|
||||||
}).timeout(const Duration(seconds: 8));
|
|
||||||
_snack(
|
_snack(
|
||||||
context,
|
context,
|
||||||
res.data['message']?.toString() ??
|
res.data['message']?.toString() ??
|
||||||
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
|
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
|
||||||
setState(() => _statusReload++);
|
setState(() => _statusReload++);
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => _snack(context, message),
|
_snack(
|
||||||
|
context,
|
||||||
|
_friendlyDioMessage(e,
|
||||||
fallback:
|
fallback:
|
||||||
'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
|
'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.'));
|
||||||
);
|
} on TimeoutException {
|
||||||
|
_snack(context,
|
||||||
|
'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.');
|
||||||
|
} catch (e) {
|
||||||
|
_snack(context, 'Invite gagal: $e');
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
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 8 karakter pairing code aktif dari User.',
|
subtitle: 'Masukkan 12 karakter Unique ID milik 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: 8,
|
maxLength: 12,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Pairing Code',
|
labelText: 'Unique User ID',
|
||||||
hintText: 'Contoh: A7K9Q2M4',
|
hintText: 'Contoh: AB1C2D3E4F5G',
|
||||||
prefixIcon: Icon(Icons.link),
|
prefixIcon: Icon(Icons.link),
|
||||||
)),
|
)),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
@ -242,8 +192,7 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final token = await sl<SecureStorage>().getAccessToken();
|
final token = await sl<SecureStorage>().getAccessToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
_active = false;
|
_active = false;
|
||||||
@ -267,18 +216,27 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
|
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
|
||||||
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
|
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
|
||||||
} else {
|
} else {
|
||||||
_status = 'Belum pairing. Bagikan pairing code aktif ke Guardian.';
|
_status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.';
|
||||||
}
|
}
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) {
|
|
||||||
_active = false;
|
_active = false;
|
||||||
_data = null;
|
_data = null;
|
||||||
_status = message;
|
_status = _friendlyDioMessage(e,
|
||||||
},
|
fallback:
|
||||||
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
|
'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);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _respond(bool accept) async {
|
Future<void> _respond(bool accept) async {
|
||||||
final pairingId = _data?['pairingId'];
|
final pairingId = _data?['pairingId'];
|
||||||
@ -287,8 +245,7 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res =
|
final res =
|
||||||
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
|
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
|
||||||
'pairingId': pairingId,
|
'pairingId': pairingId,
|
||||||
@ -299,12 +256,15 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
res.data['message']?.toString() ??
|
res.data['message']?.toString() ??
|
||||||
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
|
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
|
||||||
await _load();
|
await _load();
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => _snack(context, message),
|
_snack(context,
|
||||||
fallback: 'Gagal merespons pairing.',
|
_friendlyDioMessage(e, fallback: 'Gagal merespons pairing.'));
|
||||||
);
|
} on TimeoutException {
|
||||||
|
_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 {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@ -327,20 +287,20 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
await sl<ApiClient>()
|
await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
.delete('/shared/pairing/unpair')
|
.delete('/shared/pairing/unpair')
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
_snack(context, 'Pairing telah diputus.');
|
_snack(context, 'Pairing telah diputus.');
|
||||||
await _load();
|
await _load();
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => _snack(context, message),
|
_snack(
|
||||||
fallback: 'Gagal memutus pairing.',
|
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.'));
|
||||||
);
|
} finally {
|
||||||
if (mounted) setState(() => _responding = false);
|
if (mounted) setState(() => _responding = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -464,12 +424,8 @@ 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.title, required this.value, required this.icon});
|
||||||
required this.value,
|
|
||||||
required this.icon,
|
|
||||||
this.helper});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -489,13 +445,7 @@ 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)),
|
|
||||||
],
|
|
||||||
])),
|
])),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -514,11 +464,21 @@ void _snack(BuildContext context, String message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRemaining(int? seconds, DateTime? expiresAt) {
|
String _friendlyDioMessage(DioException e, {required String fallback}) {
|
||||||
final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
|
final data = e.response?.data;
|
||||||
if (value == null || value <= 0) return 'sudah kadaluarsa';
|
if (data is Map && data['message'] != null) return data['message'].toString();
|
||||||
final minutes = value ~/ 60;
|
if (e.response?.statusCode == 401) {
|
||||||
final secs = value % 60;
|
return 'Sesi login habis. Logout lalu login ulang.';
|
||||||
if (minutes <= 0) return '$secs detik';
|
}
|
||||||
return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
|
if (e.response?.statusCode == 403) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
2412
walkguide-mobile/walkguide_app/lib/features/screens.dart
Normal file
2412
walkguide-mobile/walkguide_app/lib/features/screens.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,6 @@ 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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -38,8 +37,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
_ok = false;
|
_ok = false;
|
||||||
_message = null;
|
_message = null;
|
||||||
});
|
});
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final clean = AppConstants.normalizeServerUrl(_url.text);
|
final clean = AppConstants.normalizeServerUrl(_url.text);
|
||||||
final res = await Dio(BaseOptions(
|
final res = await Dio(BaseOptions(
|
||||||
connectTimeout: AppConstants.pingTimeout,
|
connectTimeout: AppConstants.pingTimeout,
|
||||||
@ -49,12 +47,12 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
_message = _ok
|
_message = _ok
|
||||||
? 'Server aktif dan siap dipakai.'
|
? 'Server aktif dan siap dipakai.'
|
||||||
: 'Server merespons dengan format tidak valid.';
|
: 'Server merespons dengan format tidak valid.';
|
||||||
},
|
} catch (e) {
|
||||||
onError: (message) => _message = message,
|
_message = 'Tidak bisa terhubung. Periksa URL dan jaringan.';
|
||||||
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
|
} finally {
|
||||||
);
|
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _continue() async {
|
Future<void> _continue() async {
|
||||||
final clean = AppConstants.normalizeServerUrl(_url.text);
|
final clean = AppConstants.normalizeServerUrl(_url.text);
|
||||||
|
|||||||
@ -12,7 +12,6 @@ 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';
|
||||||
@ -79,11 +78,9 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
final res =
|
||||||
final res = await _api
|
await _api.get('/user/settings').timeout(const Duration(seconds: 6));
|
||||||
.get('/user/settings')
|
|
||||||
.timeout(const Duration(seconds: 6));
|
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
|
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
|
||||||
@ -92,33 +89,29 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
|
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
|
||||||
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
|
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
|
||||||
}
|
}
|
||||||
},
|
} catch (_) {
|
||||||
onError: (_) {},
|
// offline: tetap pakai default / nilai lokal
|
||||||
fallback: 'Settings belum bisa dimuat.',
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadPairing() async {
|
Future<void> _loadPairing() async {
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/shared/pairing/status')
|
.get('/shared/pairing/status')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
_paired = data['status'] == 'ACTIVE';
|
_paired = data['status'] == 'ACTIVE';
|
||||||
final partner =
|
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
|
||||||
data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
|
|
||||||
_pairingStatus = _paired
|
_pairingStatus = _paired
|
||||||
? 'Terhubung dengan $partner'
|
? 'Terhubung dengan $partner'
|
||||||
: data['status'] == 'PENDING'
|
: data['status'] == 'PENDING'
|
||||||
? 'Menunggu konfirmasi Guardian'
|
? 'Menunggu konfirmasi Guardian'
|
||||||
: 'Belum paired';
|
: 'Belum paired';
|
||||||
}
|
}
|
||||||
},
|
} catch (_) {
|
||||||
onError: (_) => _pairingStatus = 'Tidak bisa cek status',
|
_pairingStatus = 'Tidak bisa cek status';
|
||||||
fallback: 'Tidak bisa cek status',
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@ -129,8 +122,7 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
await sl<HapticService>().success();
|
await sl<HapticService>().success();
|
||||||
}
|
}
|
||||||
|
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
await _api.put('/user/settings', data: {
|
await _api.put('/user/settings', data: {
|
||||||
'ttsLanguage': _ttsLanguage,
|
'ttsLanguage': _ttsLanguage,
|
||||||
'ttsPitch': _ttsPitch,
|
'ttsPitch': _ttsPitch,
|
||||||
@ -140,17 +132,24 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
}).timeout(const Duration(seconds: 8));
|
}).timeout(const Duration(seconds: 8));
|
||||||
_snack('Settings tersimpan.');
|
_snack('Settings tersimpan.');
|
||||||
sl<TtsService>().speak('Settings disimpan.');
|
sl<TtsService>().speak('Settings disimpan.');
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: _snack,
|
final msg = e.response?.data['message']?.toString() ??
|
||||||
fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.',
|
'Server tidak merespons, settings lokal sudah diterapkan.';
|
||||||
);
|
_snack(msg);
|
||||||
|
} catch (_) {
|
||||||
|
_snack('Settings lokal sudah diterapkan, gagal sync ke server.');
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
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.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore();
|
_api
|
||||||
|
.post('/auth/logout')
|
||||||
|
.timeout(const Duration(seconds: 3))
|
||||||
|
.ignore();
|
||||||
if (mounted) context.go('/login');
|
if (mounted) context.go('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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';
|
||||||
@ -95,17 +94,15 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
// ── API Calls ─────────────────────────────────────────────────────────────
|
// ── API Calls ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<Position?> _getPosition() async {
|
Future<Position?> _getPosition() async {
|
||||||
return runFriendly<Position>(
|
try {
|
||||||
() async {
|
|
||||||
final permission = await Geolocator.requestPermission();
|
final permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied ||
|
if (permission == LocationPermission.denied ||
|
||||||
permission == LocationPermission.deniedForever) return null;
|
permission == LocationPermission.deniedForever) return null;
|
||||||
return await Geolocator.getCurrentPosition()
|
return await Geolocator.getCurrentPosition()
|
||||||
.timeout(const Duration(seconds: 6));
|
.timeout(const Duration(seconds: 6));
|
||||||
},
|
} catch (_) {
|
||||||
onError: (_) {},
|
return null;
|
||||||
fallback: 'Lokasi belum bisa dibaca.',
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
@ -113,8 +110,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
_historyLoading = true;
|
_historyLoading = true;
|
||||||
_historyError = null;
|
_historyError = null;
|
||||||
});
|
});
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final res = await _api.get('/user/sos-events',
|
final res = await _api.get('/user/sos-events',
|
||||||
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
|
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
@ -126,12 +122,15 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
.toList()
|
.toList()
|
||||||
: <_SosEvent>[];
|
: <_SosEvent>[];
|
||||||
setState(() => _events = items);
|
setState(() => _events = items);
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: (message) => setState(() => _historyError = message),
|
final msg = e.response?.data?['message']?.toString();
|
||||||
fallback: 'Tidak bisa memuat riwayat SOS.',
|
setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.');
|
||||||
);
|
} catch (_) {
|
||||||
|
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 {
|
||||||
if (_sending) return;
|
if (_sending) return;
|
||||||
@ -179,8 +178,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
|
|
||||||
Future<void> _sendSos() async {
|
Future<void> _sendSos() async {
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
await runFriendlyAction(
|
try {
|
||||||
() async {
|
|
||||||
final pos = await _getPosition();
|
final pos = await _getPosition();
|
||||||
await _api.post('/user/sos', data: {
|
await _api.post('/user/sos', data: {
|
||||||
'triggerType': 'BUTTON',
|
'triggerType': 'BUTTON',
|
||||||
@ -191,12 +189,15 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
||||||
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
||||||
await _loadHistory();
|
await _loadHistory();
|
||||||
},
|
} on DioException catch (e) {
|
||||||
onError: _snack,
|
final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS';
|
||||||
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
|
_snack(msg);
|
||||||
);
|
} catch (e) {
|
||||||
|
_snack('Gagal kirim SOS: $e');
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Build ──────────────────────────────────────────────────────────────────
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
// 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';
|
||||||
@ -33,7 +36,6 @@ 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() {
|
||||||
@ -57,7 +59,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_active = next;
|
_active = next;
|
||||||
_status = next ? _activeStatusText() : 'Stopped';
|
_status = next ? 'Camera stream active. YOLO ready.' : 'Stopped';
|
||||||
});
|
});
|
||||||
await sl<ApiClient>()
|
await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
@ -65,30 +67,13 @@ 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(
|
||||||
backCamera,
|
cameras.first,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||||
@ -101,13 +86,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
try {
|
try {
|
||||||
await controller.startImageStream(_onCameraImage);
|
await controller.startImageStream(_onCameraImage);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() => _status = kIsWeb
|
// Preview still works; manual Demo Detect remains available.
|
||||||
? '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.');
|
setState(() => _status = 'Camera unavailable. Demo mode active.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,28 +118,24 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runYolo(CameraImage image) async {
|
Future<void> _runYolo(CameraImage image) async {
|
||||||
final detector = sl<YoloDetector>();
|
final detection = await sl<YoloDetector>().detect(image);
|
||||||
final detection = await detector.detect(image, confidenceThreshold: 0.25);
|
if (detection == null || !mounted) return;
|
||||||
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: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (!forceAlert &&
|
if (!forceAlert &&
|
||||||
@ -208,12 +187,6 @@ 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,
|
||||||
@ -226,7 +199,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
left: 16,
|
left: 16,
|
||||||
child: _Pill(
|
child: _Pill(
|
||||||
text:
|
text:
|
||||||
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
|
'${_lastDetection!.label} ${_lastDetection!.directionName}',
|
||||||
color: Colors.redAccent),
|
color: Colors.redAccent),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -250,6 +223,12 @@ 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'))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -258,74 +237,229 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetectionOverlayPainter extends CustomPainter {
|
// ---------------------------------------------------------------------------
|
||||||
final DetectionResult detection;
|
// AiBenchmarkScreen
|
||||||
const _DetectionOverlayPainter(this.detection);
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AiBenchmarkScreen extends StatefulWidget {
|
||||||
|
const AiBenchmarkScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
|
||||||
final box = detection.box;
|
}
|
||||||
if (box == null) return;
|
|
||||||
|
|
||||||
final sx = size.width / ObstacleAnalyzer.frameWidth;
|
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||||
final sy = size.height / ObstacleAnalyzer.frameHeight;
|
static const _runsKey = 'ai_benchmark_runs';
|
||||||
final rect = Rect.fromLTRB(
|
List<String> _models = const [];
|
||||||
box.left * sx,
|
String _selectedModel = AppConstants.yoloModelPath;
|
||||||
box.top * sy,
|
List<Map<String, dynamic>> _runs = const [];
|
||||||
box.right * sx,
|
bool _running = false;
|
||||||
box.bottom * sy,
|
|
||||||
);
|
@override
|
||||||
final color = switch (detection.direction) {
|
void initState() {
|
||||||
ObstacleDirection.center => const Color(0xFFEF4444),
|
super.initState();
|
||||||
ObstacleDirection.left => const Color(0xFFF59E0B),
|
_load();
|
||||||
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
canvas.drawRRect(
|
Future<int> _measureCapture() async {
|
||||||
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
|
final watch = Stopwatch()..start();
|
||||||
Paint()
|
CameraController? controller;
|
||||||
..color = color.withValues(alpha: 0.12)
|
try {
|
||||||
..style = PaintingStyle.fill,
|
final cameras =
|
||||||
);
|
await availableCameras().timeout(const Duration(seconds: 3));
|
||||||
canvas.drawRRect(
|
if (cameras.isNotEmpty) {
|
||||||
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
|
controller = CameraController(cameras.first, ResolutionPreset.low,
|
||||||
Paint()
|
enableAudio: false);
|
||||||
..color = color
|
await controller.initialize().timeout(const Duration(seconds: 5));
|
||||||
..strokeWidth = 3
|
await controller.takePicture().timeout(const Duration(seconds: 5));
|
||||||
..style = PaintingStyle.stroke,
|
}
|
||||||
);
|
} catch (_) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||||
|
} finally {
|
||||||
|
await controller?.dispose();
|
||||||
|
}
|
||||||
|
watch.stop();
|
||||||
|
return watch.elapsedMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
final label =
|
Future<void> _clearRuns() async {
|
||||||
'${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}';
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final textPainter = TextPainter(
|
await prefs.remove(_runsKey);
|
||||||
text: TextSpan(
|
setState(() => _runs = const []);
|
||||||
text: label,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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
|
@override
|
||||||
bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) {
|
Widget build(BuildContext context) {
|
||||||
return oldDelegate.detection != detection;
|
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 Map<String, dynamic> run;
|
||||||
|
const _BenchmarkCard({required this.run});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
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']}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,3 +533,88 @@ 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user