Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..

This commit is contained in:
Wowieee4 2026-05-26 21:09:13 +07:00
parent b8ad8df993
commit a629357e8c
72 changed files with 2566 additions and 530 deletions

View File

@ -98,6 +98,13 @@ public class GuardianController {
"SOS diakui")); "SOS diakui"));
} }
@PutMapping("/sos/{id}/resolve")
public ResponseEntity<ApiResponse<SosEventResponse>> resolveSos(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.resolveSos(SecurityHelper.getCurrentUserId(), id),
"SOS ditangani"));
}
@GetMapping("/ai-config") @GetMapping("/ai-config")
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() { public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
// Guardian lihat config user yang dipair // Guardian lihat config user yang dipair
@ -117,7 +124,7 @@ public class GuardianController {
@GetMapping("/voice-commands") @GetMapping("/voice-commands")
public ResponseEntity<ApiResponse<?>> getVoiceCommands() { public ResponseEntity<ApiResponse<?>> getVoiceCommands() {
return ResponseEntity.ok(ApiResponse.ok( return ResponseEntity.ok(ApiResponse.ok(
voiceCommandService.getAll(SecurityHelper.getCurrentUserId()), voiceCommandService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
"Voice commands")); "Voice commands"));
} }
@ -132,10 +139,18 @@ public class GuardianController {
@GetMapping("/shortcuts") @GetMapping("/shortcuts")
public ResponseEntity<ApiResponse<?>> getShortcuts() { public ResponseEntity<ApiResponse<?>> getShortcuts() {
return ResponseEntity.ok(ApiResponse.ok( return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()), hardwareShortcutService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
"Hardware shortcuts")); "Hardware shortcuts"));
} }
@PutMapping("/shortcuts")
public ResponseEntity<ApiResponse<HardwareShortcutResponse>> updateShortcut(
@RequestBody HardwareShortcutUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.updateByGuardian(SecurityHelper.getCurrentUserId(), req),
"Hardware shortcut diperbarui"));
}
@GetMapping("/geofence") @GetMapping("/geofence")
public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() { public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() {
return ResponseEntity.ok(ApiResponse.ok( return ResponseEntity.ok(ApiResponse.ok(

View File

@ -3,8 +3,6 @@ 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.*; import com.walkguide.dto.response.*;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper; import com.walkguide.security.SecurityHelper;
import com.walkguide.service.*; import com.walkguide.service.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -29,21 +27,13 @@ public class UserController {
private final AiConfigService aiConfigService; private final AiConfigService aiConfigService;
private final VoiceCommandService voiceCommandService; private final VoiceCommandService voiceCommandService;
private final HardwareShortcutService hardwareShortcutService; private final HardwareShortcutService hardwareShortcutService;
private final UserRepository userRepository; private final UserService userService;
@GetMapping("/profile") @GetMapping("/profile")
public ResponseEntity<ApiResponse<?>> getProfile() { public ResponseEntity<ApiResponse<?>> getProfile() {
Long userId = SecurityHelper.getCurrentUserId(); return ResponseEntity.ok(ApiResponse.ok(
var user = userRepository.findById(userId) userService.getProfile(SecurityHelper.getCurrentUserId()),
.orElseThrow(() -> new RuntimeException("User tidak ditemukan")); "Profil user"));
var profile = java.util.Map.of(
"id", user.getId(),
"email", user.getEmail(),
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
"role", user.getRole(),
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
);
return ResponseEntity.ok(ApiResponse.ok(profile, "Profil user"));
} }
@GetMapping("/settings") @GetMapping("/settings")
@ -163,19 +153,13 @@ public class UserController {
@PostMapping("/walkguide/start") @PostMapping("/walkguide/start")
public ResponseEntity<ApiResponse<Void>> walkGuideStart() { public ResponseEntity<ApiResponse<Void>> walkGuideStart() {
Long userId = SecurityHelper.getCurrentUserId(); userService.logWalkGuideStart(SecurityHelper.getCurrentUserId());
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_START,
"WalkGuide dimulai", null));
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai")); return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai"));
} }
@PostMapping("/walkguide/stop") @PostMapping("/walkguide/stop")
public ResponseEntity<ApiResponse<Void>> walkGuideStop() { public ResponseEntity<ApiResponse<Void>> walkGuideStop() {
Long userId = SecurityHelper.getCurrentUserId(); userService.logWalkGuideStop(SecurityHelper.getCurrentUserId());
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_STOP,
"WalkGuide dihentikan", null));
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan")); return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan"));
} }
} }

View File

@ -2,9 +2,12 @@ package com.walkguide.service;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest; import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse; import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.HardwareShortcutKey; import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository; import com.walkguide.repository.HardwareShortcutRepository;
import com.walkguide.repository.PairingRelationRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@ -15,6 +18,7 @@ import java.util.stream.Collectors;
public class HardwareShortcutService { public class HardwareShortcutService {
private final HardwareShortcutRepository hardwareShortcutRepository; private final HardwareShortcutRepository hardwareShortcutRepository;
private final PairingRelationRepository pairingRelationRepository;
public List<HardwareShortcutResponse> getAll(Long userId) { public List<HardwareShortcutResponse> getAll(Long userId) {
return hardwareShortcutRepository.findByUserId(userId).stream() return hardwareShortcutRepository.findByUserId(userId).stream()
@ -25,6 +29,13 @@ public class HardwareShortcutService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public List<HardwareShortcutResponse> getAllForGuardian(Long guardianId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return getAll(pairing.getUser().getId());
}
// Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian // Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian
public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) { public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) {
HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey()); HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey());
@ -43,4 +54,11 @@ public class HardwareShortcutService {
.buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode()) .buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode())
.enabled(shortcut.getEnabled()).build(); .enabled(shortcut.getEnabled()).build();
} }
public HardwareShortcutResponse updateByGuardian(Long guardianId, HardwareShortcutUpdateRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return update(pairing.getUser().getId(), req);
}
} }

View File

@ -87,6 +87,7 @@ public class SosService {
public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) { public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) {
SosEvent sos = sosEventRepository.findById(sosId) SosEvent sos = sosEventRepository.findById(sosId)
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan")); .orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
assertGuardianOwnsSos(guardianId, sos.getUserId());
sos.setStatus(SosStatus.ACKNOWLEDGED); sos.setStatus(SosStatus.ACKNOWLEDGED);
sos.setAcknowledgedAt(LocalDateTime.now()); sos.setAcknowledgedAt(LocalDateTime.now());
@ -111,6 +112,33 @@ public class SosService {
return toResponse(sos); return toResponse(sos);
} }
@Transactional
public SosEventResponse resolveSos(Long guardianId, Long sosId) {
SosEvent sos = sosEventRepository.findById(sosId)
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
assertGuardianOwnsSos(guardianId, sos.getUserId());
if (sos.getAcknowledgedAt() == null) {
sos.setAcknowledgedAt(LocalDateTime.now());
}
sos.setStatus(SosStatus.RESOLVED);
sosEventRepository.save(sos);
User user = userRepository.findById(sos.getUserId())
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
activityLogService.createLog(user, ActivityLogType.SOS_ACKNOWLEDGED,
"Guardian menandai SOS sudah ditangani", null);
pairingRelationRepository.findByUser_IdAndStatus(sos.getUserId(), PairingStatus.ACTIVE)
.ifPresent(pairing -> fcmService.sendToToken(
pairing.getUser().getFcmToken(),
"SOS Sudah Ditangani",
"Guardian kamu sudah menandai SOS sebagai selesai.",
Map.of("type", "SOS_RESOLVED")));
return toResponse(sos);
}
public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) { public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) {
return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable) return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse); .map(this::toResponse);
@ -125,6 +153,15 @@ public class SosService {
.map(this::toResponse); .map(this::toResponse);
} }
private void assertGuardianOwnsSos(Long guardianId, Long userId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Tidak ada user yang dipair"));
if (!pairing.getUser().getId().equals(userId)) {
throw new ResourceNotFoundException("SOS event tidak ditemukan untuk guardian ini");
}
}
private SosEventResponse toResponse(SosEvent s) { private SosEventResponse toResponse(SosEvent s) {
return SosEventResponse.builder() return SosEventResponse.builder()
.id(s.getId()) .id(s.getId())

View File

@ -0,0 +1,40 @@
package com.walkguide.service;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
public Map<String, Object> getProfile(Long userId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
return Map.of(
"id", user.getId(),
"email", user.getEmail(),
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
"role", user.getRole(),
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
);
}
public void logWalkGuideStart(Long userId) {
userRepository.findById(userId).ifPresent(user ->
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_START,
"WalkGuide dimulai", null));
}
public void logWalkGuideStop(Long userId) {
userRepository.findById(userId).ifPresent(user ->
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_STOP,
"WalkGuide dihentikan", null));
}
}

View File

@ -52,6 +52,13 @@ public class VoiceCommandService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public List<VoiceCommandResponse> getAllForGuardian(Long guardianId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return getAll(pairing.getUser().getId());
}
public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) { public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) {
var pairing = pairingRelationRepository var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)

View File

@ -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,7 +17,7 @@ spring:
format_sql: true format_sql: true
jwt: jwt:
secret: ${JWT_SECRET} secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
expiration: ${JWT_EXPIRATION:86400000} expiration: ${JWT_EXPIRATION:86400000}
agora: agora:

View File

@ -2,9 +2,9 @@
server.port=${SERVER_PORT:8080} server.port=${SERVER_PORT:8080}
# ===== POSTGRESQL CONNECTION ===== # ===== POSTGRESQL CONNECTION =====
spring.datasource.url=${DB_URL} spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
spring.datasource.username=${DB_USERNAME} spring.datasource.username=${DB_USERNAME:5803024001}
spring.datasource.password=${DB_PASSWORD} spring.datasource.password=${DB_PASSWORD:pw5803024001}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# ===== JPA / HIBERNATE ===== # ===== JPA / HIBERNATE =====
@ -19,7 +19,7 @@ spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true spring.flyway.baseline-on-migrate=true
# ===== JWT ===== # ===== JWT =====
jwt.secret=${JWT_SECRET} jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
jwt.expiration=${JWT_EXPIRATION:86400000} jwt.expiration=${JWT_EXPIRATION:86400000}
# ===== SWAGGER ===== # ===== SWAGGER =====

View File

@ -273,6 +273,15 @@ paths:
schema: { type: integer, format: int64 } schema: { type: integer, format: int64 }
responses: responses:
"200": { description: SOS acknowledged } "200": { description: SOS acknowledged }
/guardian/sos/{id}/resolve:
put:
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
responses:
"200": { description: SOS handled and resolved }
/guardian/ai-config: /guardian/ai-config:
get: get:
responses: responses:
@ -291,6 +300,9 @@ paths:
get: get:
responses: responses:
"200": { description: Paired user shortcuts } "200": { description: Paired user shortcuts }
put:
responses:
"200": { description: Paired user shortcut updated }
/guardian/geofence: /guardian/geofence:
get: get:
responses: responses:

View File

@ -8,10 +8,10 @@ import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest(properties = { @SpringBootTest(properties = {
"spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test", "spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test",
"spring.datasource.username=test", "spring.datasource.username=test",
"spring.datasource.password=test", "spring.datasource.password=${TEST_DB_PASSWORD}",
"spring.flyway.enabled=false", "spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none", "spring.jpa.hibernate.ddl-auto=none",
"jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970" "jwt.secret=${TEST_JWT_SECRET}"
}) })
class DemoApplicationTests { class DemoApplicationTests {

View File

@ -211,6 +211,24 @@ class GuardianControllerTest {
} }
} }
@Test
@DisplayName("PUT /api/v1/guardian/sos/{id}/resolve - harus tandai SOS ditangani")
void resolveSos_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
SosEventResponse resp = new SosEventResponse();
when(sosService.resolveSos(2L, 10L)).thenReturn(resp);
mockMvc.perform(put("/api/v1/guardian/sos/10/resolve")
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("SOS ditangani"));
verify(sosService).resolveSos(2L, 10L);
}
}
// ===== AI CONFIG ===== // ===== AI CONFIG =====
@Test @Test
@ -254,7 +272,7 @@ class GuardianControllerTest {
void getVoiceCommands_shouldReturn200() throws Exception { void getVoiceCommands_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(voiceCommandService.getAll(2L)).thenReturn(List.of()); when(voiceCommandService.getAllForGuardian(2L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/guardian/voice-commands")) mockMvc.perform(get("/api/v1/guardian/voice-commands"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -288,7 +306,7 @@ class GuardianControllerTest {
void getShortcuts_shouldReturn200() throws Exception { void getShortcuts_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) { try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(hardwareShortcutService.getAll(2L)).thenReturn(List.of()); when(hardwareShortcutService.getAllForGuardian(2L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/guardian/shortcuts")) mockMvc.perform(get("/api/v1/guardian/shortcuts"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -296,6 +314,27 @@ class GuardianControllerTest {
} }
} }
@Test
@DisplayName("PUT /api/v1/guardian/shortcuts - harus update shortcut user yang dipair")
void updateShortcut_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
HardwareShortcutResponse resp = new HardwareShortcutResponse();
when(hardwareShortcutService.updateByGuardian(eq(2L), any())).thenReturn(resp);
mockMvc.perform(put("/api/v1/guardian/shortcuts")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Hardware shortcut diperbarui"));
verify(hardwareShortcutService).updateByGuardian(eq(2L), any(HardwareShortcutUpdateRequest.class));
}
}
// ===== GEOFENCE ===== // ===== GEOFENCE =====
@Test @Test

View File

@ -1,7 +1,5 @@
package com.walkguide.integration; package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.LoginRequest; import com.walkguide.dto.request.LoginRequest;
import com.walkguide.dto.request.RefreshTokenRequest; import com.walkguide.dto.request.RefreshTokenRequest;
@ -25,7 +23,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/ */
@DisplayName("Integration Test — Auth Flow (Testcontainers)") @DisplayName("Integration Test — Auth Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class AuthIntegrationTest extends AbstractIntegrationTest { class AuthIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "integ_user@walkguide.test"; private static final String USER_EMAIL = "integ_user@walkguide.test";

View File

@ -1,7 +1,5 @@
package com.walkguide.integration; package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.InviteUserRequest; import com.walkguide.dto.request.InviteUserRequest;
import com.walkguide.dto.request.PairingResponseRequest; import com.walkguide.dto.request.PairingResponseRequest;
@ -26,7 +24,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/ */
@DisplayName("Integration Test — Pairing Flow (Testcontainers)") @DisplayName("Integration Test — Pairing Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class PairingIntegrationTest extends AbstractIntegrationTest { class PairingIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "pairing_user@walkguide.test"; private static final String USER_EMAIL = "pairing_user@walkguide.test";

View File

@ -1,7 +1,5 @@
package com.walkguide.integration; package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.*; import com.walkguide.dto.request.*;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
@ -28,7 +26,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/ */
@DisplayName("Integration Test — User Core Features (Testcontainers)") @DisplayName("Integration Test — User Core Features (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class UserFeatureIntegrationTest extends AbstractIntegrationTest { class UserFeatureIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "feature_user@walkguide.test"; private static final String USER_EMAIL = "feature_user@walkguide.test";

View File

@ -2,10 +2,14 @@ package com.walkguide.service;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest; import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse; import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.HardwareShortcut; import com.walkguide.entity.HardwareShortcut;
import com.walkguide.entity.User;
import com.walkguide.enums.HardwareShortcutKey; import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository; import com.walkguide.repository.HardwareShortcutRepository;
import com.walkguide.repository.PairingRelationRepository;
import org.junit.jupiter.api.BeforeEach; 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;
@ -16,6 +20,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@ -27,6 +32,8 @@ class HardwareShortcutServiceTest {
@Mock @Mock
HardwareShortcutRepository hardwareShortcutRepository; HardwareShortcutRepository hardwareShortcutRepository;
@Mock
PairingRelationRepository pairingRelationRepository;
@InjectMocks @InjectMocks
HardwareShortcutService hardwareShortcutService; HardwareShortcutService hardwareShortcutService;
@ -180,4 +187,29 @@ class HardwareShortcutServiceTest {
assertThatThrownBy(() -> hardwareShortcutService.update(10L, req)) assertThatThrownBy(() -> hardwareShortcutService.update(10L, req))
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(IllegalArgumentException.class);
} }
@Test
@DisplayName("updateByGuardian - harus update shortcut milik user yang dipair")
void updateByGuardian_shouldUpdatePairedUserShortcut() {
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
req.setShortcutKey("CALL_GUARDIAN");
req.setButtonName("Power Double Tap");
User guardian = User.builder().id(1L).build();
User user = User.builder().id(10L).build();
PairingRelation pairing = PairingRelation.builder()
.guardian(guardian).user(user).status(PairingStatus.ACTIVE).build();
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(pairing));
when(hardwareShortcutRepository.findByUserId(10L))
.thenReturn(List.of(shortcutCallGuardian));
when(hardwareShortcutRepository.save(any(HardwareShortcut.class)))
.thenAnswer(inv -> inv.getArgument(0));
HardwareShortcutResponse result = hardwareShortcutService.updateByGuardian(1L, req);
assertThat(result.getButtonName()).isEqualTo("Power Double Tap");
verify(hardwareShortcutRepository).findByUserId(10L);
}
} }

View File

@ -160,6 +160,8 @@ class SosServiceTest {
@DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED") @DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED")
void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() { void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0)); when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
@ -180,6 +182,8 @@ class SosServiceTest {
@DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon") @DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon")
void acknowledgeSos_activePairingForUser_shouldNotifyUser() { void acknowledgeSos_activePairingForUser_shouldNotifyUser() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0)); when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
@ -206,6 +210,28 @@ class SosServiceTest {
.hasMessageContaining("SOS event tidak ditemukan"); .hasMessageContaining("SOS event tidak ditemukan");
} }
@Test
@DisplayName("resolveSos - SOS ditemukan: harus ubah status ke RESOLVED")
void resolveSos_sosFound_shouldChangeStatusToResolved() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
SosEventResponse result = sosService.resolveSos(1L, 50L);
assertThat(result).isNotNull();
assertThat(result.getStatus()).isEqualTo("RESOLVED");
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
verify(sosEventRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(SosStatus.RESOLVED);
assertThat(captor.getValue().getAcknowledgedAt()).isNotNull();
}
// ===== getSosEvents TESTS ===== // ===== getSosEvents TESTS =====
@Test @Test

View File

@ -0,0 +1,16 @@
# WalkGuide Flutter Physical Device Benchmark Checklist
Use this file as the final evidence checklist. Do not replace these rows with emulator data.
| Metric | Command / Tool | Required Evidence |
|---|---|---|
| Cold start | `flutter run --profile --trace-startup` | `timeToFirstFrameMicros`, screenshot/log |
| Frame jank | DevTools Performance in profile mode | 90%+ frames under 16 ms |
| Memory baseline | DevTools Memory after app launch | Heap MB screenshot |
| Memory leak check | Navigate all major screens 10x | Heap growth before/after |
| CPU during YOLO | DevTools CPU profiler while WalkGuide active | Flame graph screenshot |
| API latency | Dio latency logs | p95 latency table |
| APK size | `flutter build apk --release --analyze-size` | Size analysis artifact |
| Delta table | Mid-sprint vs final | Week 5 vs final comparison |
Final benchmark must be captured on a physical Android phone in profile mode.

View File

@ -2,56 +2,82 @@ import 'package:get_it/get_it.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../core/constants/app_constants.dart'; import '../core/constants/app_constants.dart';
import '../core/ai/obstacle_alert_strategy.dart';
import '../core/ai/obstacle_analyzer.dart'; import '../core/ai/obstacle_analyzer.dart';
import '../core/ai/yolo_detector.dart'; import '../core/ai/yolo_detector.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/call_service.dart'; import '../core/services/call_service.dart';
import '../core/services/fcm_service.dart'; import '../core/services/fcm_service.dart';
import '../core/services/hardware_shortcut_listener.dart';
import '../core/services/location_reporter_service.dart'; import '../core/services/location_reporter_service.dart';
import '../core/services/offline_queue_service.dart'; import '../core/services/offline_queue_service.dart';
import '../core/services/stt_service.dart'; import '../core/services/stt_service.dart';
import '../core/services/tts_service.dart'; import '../core/services/tts_service.dart';
import '../core/services/voice_command_handler.dart'; import '../core/services/voice_command_handler.dart';
import '../core/services/websocket_service.dart'; import '../core/services/websocket_service.dart';
import '../core/storage/local_database.dart';
import '../core/storage/secure_storage.dart'; import '../core/storage/secure_storage.dart';
import '../core/utils/init_guard.dart';
import '../features/notifications/application/notification_cubit.dart';
import '../features/notifications/data/repositories/notification_repository_impl.dart';
import '../features/notifications/domain/repositories/notification_repository.dart';
import '../features/sos/application/sos_cubit.dart';
import '../features/sos/data/repositories/sos_repository_impl.dart';
import '../features/sos/domain/repositories/sos_repository.dart';
import '../features/walk_guide/application/walk_guide_cubit.dart';
import '../features/walk_guide/data/repositories/walk_guide_repository_impl.dart';
import '../features/walk_guide/domain/repositories/walk_guide_repository.dart';
final sl = GetIt.instance; final sl = GetIt.instance;
Future<void> initDependencies() async { Future<void> initDependencies() async {
sl.registerLazySingleton<SecureStorage>(() => SecureStorage()); sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
sl.registerLazySingleton<LocalDatabase>(() => LocalDatabase());
sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>())); sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>()));
sl.registerLazySingleton<TtsService>(() => TtsService()); sl.registerLazySingleton<TtsService>(() => TtsService());
sl.registerLazySingleton<SttService>(() => SttService()); sl.registerLazySingleton<SttService>(() => SttService());
sl.registerLazySingleton<HapticService>(() => HapticService()); sl.registerLazySingleton<HapticService>(() => HapticService());
sl.registerLazySingleton<ObstacleAlertStrategy>(
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
);
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer()); sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>())); sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
sl.registerLazySingleton<OfflineQueueService>(() => OfflineQueueService()); sl.registerLazySingleton<OfflineQueueService>(
() => OfflineQueueService(sl<LocalDatabase>()),
);
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>())); sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>())); sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>())); sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>())); sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
sl.registerLazySingleton<HardwareShortcutListener>(
() => HardwareShortcutListener(sl<ApiClient>()),
);
sl.registerLazySingleton<VoiceCommandHandler>( sl.registerLazySingleton<VoiceCommandHandler>(
() => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()), () => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()),
); );
sl.registerLazySingleton<WalkGuideRepository>(
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
);
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
sl.registerLazySingleton<NotificationRepository>(
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
);
sl.registerFactory<NotificationCubit>(
() => NotificationCubit(sl<NotificationRepository>()),
);
final serverUrl = await AppConstants.getServerUrl(); final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) { if (serverUrl != null && serverUrl.isNotEmpty) {
await sl<ApiClient>().init(serverUrl); await sl<ApiClient>().init(serverUrl);
} }
try { await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
await sl<TtsService>().init();
} catch (e) {
debugPrint('TTS init skipped: $e');
}
await sl<YoloDetector>().init(); await sl<YoloDetector>().init();
if (!kIsWeb) { if (!kIsWeb) {
try { await ignoreInitFailure(() => sl<SttService>().init(), label: 'STT init');
await sl<SttService>().init();
} catch (e) {
debugPrint('STT init skipped: $e');
}
} }
sl<VoiceCommandHandler>().loadDefaultCommands(); sl<VoiceCommandHandler>().loadDefaultCommands();
if (!kIsWeb) { if (!kIsWeb) {

View File

@ -1,34 +1,42 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../app/injection_container.dart';
import '../core/constants/app_constants.dart'; import '../core/constants/app_constants.dart';
import '../features/activity_log/activity_log_screen.dart' as activity; import '../core/storage/secure_storage.dart';
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark; import '../features/activity_log/presentation/screens/activity_log_screen.dart'
as activity;
import '../features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart'
as benchmark;
import '../features/auth/login_screen.dart' as auth_login; import '../features/auth/login_screen.dart' as auth_login;
import '../features/auth/register_screen.dart' as auth_register; import '../features/auth/register_screen.dart' as auth_register;
import '../features/auth/splash_screen.dart' as auth_splash; import '../features/auth/splash_screen.dart' as auth_splash;
import '../features/call/call_screen.dart' as call; import '../features/call/presentation/screens/call_screen.dart' as call;
import '../features/guardian_dashboard/guardian_activity_log_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart'
as guardian_logs; as guardian_logs;
import '../features/guardian_dashboard/guardian_ai_config_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart'
as guardian_ai; as guardian_ai;
import '../features/guardian_dashboard/guardian_map_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_map_screen.dart'
as guardian_map; as guardian_map;
import '../features/guardian_dashboard/guardian_send_notification_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart'
as guardian_send; as guardian_send;
import '../features/guardian_dashboard/guardian_settings_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart'
as guardian_settings; as guardian_settings;
import '../features/guardian_dashboard/guardian_tools_screen.dart' import '../features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart'
as guardian_tools; as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart' import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home; as guardian_home;
import '../features/navigation_mode/navigation_mode_screen.dart' as nav; import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
import '../features/notifications/notification_screen.dart' as notifications; as nav;
import '../features/pairing/pairing_screens.dart' as pairing; import '../features/notifications/presentation/screens/notification_screen.dart'
as notifications;
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
import '../features/server_connect/server_connect_server.dart' import '../features/server_connect/server_connect_server.dart'
as server_connect; as server_connect;
import '../features/settings/user_settings_screen.dart' as user_settings; import '../features/settings/presentation/screens/user_settings_screen.dart'
import '../features/sos/sos_screen.dart' as sos; as user_settings;
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide; import '../features/sos/presentation/screens/sos_screen.dart' as sos;
import '../features/walk_guide/presentation/screens/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(
@ -36,6 +44,10 @@ final GoRouter appRouter = GoRouter(
redirect: (context, state) async { redirect: (context, state) async {
final path = state.matchedLocation; final path = state.matchedLocation;
final serverUrl = await AppConstants.getServerUrl(); final serverUrl = await AppConstants.getServerUrl();
final isPublicRoute = path == '/server-connect' ||
path == '/splash' ||
path == '/login' ||
path == '/register';
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') { if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
return '/server-connect'; return '/server-connect';
@ -45,6 +57,31 @@ final GoRouter appRouter = GoRouter(
serverUrl.isNotEmpty) { serverUrl.isNotEmpty) {
return '/splash'; return '/splash';
} }
if (serverUrl == null || serverUrl.isEmpty) return null;
final storage = sl<SecureStorage>();
final token = await storage.getAccessToken();
final role = await storage.getUserRole();
if ((token == null || token.isEmpty) && !isPublicRoute) {
return '/login';
}
if (token != null && token.isNotEmpty) {
final home = role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: role == 'ROLE_USER'
? '/user/walkguide'
: '/login';
if (path == '/splash' || path == '/login' || path == '/register') {
return home;
}
if (path.startsWith('/guardian') && role != 'ROLE_GUARDIAN') {
return '/user/walkguide';
}
if (path.startsWith('/user') && role != 'ROLE_USER') {
return '/guardian/dashboard';
}
}
return null; return null;
}, },
routes: [ routes: [

View File

@ -0,0 +1,57 @@
import '../services/haptic_service.dart';
import '../services/tts_service.dart';
import 'obstacle_analyzer.dart';
abstract class ObstacleAlertStrategy {
Future<void> alert(DetectionResult detection);
}
class TtsOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
final TtsService _ttsService;
const TtsOnlyObstacleAlertStrategy(this._ttsService);
@override
Future<void> alert(DetectionResult detection) {
return _ttsService.speakImmediate(detection.spokenId);
}
}
class HapticOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
final HapticService _hapticService;
const HapticOnlyObstacleAlertStrategy(this._hapticService);
@override
Future<void> alert(DetectionResult detection) {
return _vibrateByDistance(detection);
}
Future<void> _vibrateByDistance(DetectionResult detection) {
final distance = detection.estimatedDistance.toLowerCase();
if (distance.startsWith('very close')) {
return _hapticService.obstacleVeryClose();
}
if (distance.startsWith('close')) {
return _hapticService.obstacleClose();
}
return _hapticService.obstacleMedium();
}
}
class TtsWithHapticObstacleAlertStrategy implements ObstacleAlertStrategy {
final TtsService _ttsService;
final HapticService _hapticService;
const TtsWithHapticObstacleAlertStrategy(
this._ttsService,
this._hapticService,
);
@override
Future<void> alert(DetectionResult detection) async {
final haptic = HapticOnlyObstacleAlertStrategy(_hapticService);
await haptic.alert(detection);
await _ttsService.speakImmediate(detection.spokenId);
}
}

View File

@ -1,19 +1,28 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
class ApiService { import 'constants/app_constants.dart';
static const baseUrl = String.fromEnvironment(
'WALKGUIDE_API_BASE_URL',
defaultValue: 'http://202.46.28.160:8080/api/v1',
);
final Dio _dio = Dio(BaseOptions( @Deprecated('Use ApiClient for authenticated requests. Kept for legacy callers.')
baseUrl: baseUrl, class ApiService {
connectTimeout: const Duration(seconds: 5), ApiService._(String baseUrl)
headers: { : _dio = Dio(BaseOptions(
'Content-Type': 'application/json', baseUrl: baseUrl,
'Accept': 'application/json', connectTimeout: const Duration(seconds: 5),
}, headers: const {
)); 'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
final Dio _dio;
static Future<ApiService> create() async {
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl == null || serverUrl.isEmpty) {
throw StateError('WalkGuide server URL has not been configured.');
}
return ApiService._(AppConstants.buildApiUrl(serverUrl));
}
Future<Response> post(String path, Map<String, dynamic> data) async { Future<Response> post(String path, Map<String, dynamic> data) async {
return await _dio.post(path, data: data); return await _dio.post(path, data: data);

View File

@ -0,0 +1,31 @@
class AppStrings {
final String localeCode;
const AppStrings(this.localeCode);
static const supportedLocales = ['id-ID', 'en-US'];
String get walkGuideStarted => _pick(
id: 'WalkGuide dimulai',
en: 'WalkGuide started',
);
String get walkGuideStopped => _pick(
id: 'WalkGuide dihentikan',
en: 'WalkGuide stopped',
);
String get sosSent => _pick(
id: 'SOS terkirim. Guardian sudah diberi tahu.',
en: 'SOS sent. Your guardian has been alerted.',
);
String get notificationsOpened => _pick(
id: 'Notifikasi dibuka',
en: 'Notifications opened',
);
String _pick({required String id, required String en}) {
return localeCode == 'en-US' ? en : id;
}
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/services.dart';
import '../network/api_client.dart';
enum HardwareShortcutAction {
callGuardian,
startWalkguide,
stopWalkguide,
sendSos,
openNotification,
}
class HardwareShortcutBinding {
final HardwareShortcutAction action;
final int buttonCode;
final String buttonName;
final bool enabled;
const HardwareShortcutBinding({
required this.action,
required this.buttonCode,
required this.buttonName,
required this.enabled,
});
}
class HardwareShortcutListener {
final ApiClient _apiClient;
final Map<int, HardwareShortcutBinding> _bindings = {};
bool _listening = false;
void Function(HardwareShortcutAction action)? _onAction;
void Function(int buttonCode, String buttonName)? _captureCallback;
HardwareShortcutListener(this._apiClient);
Future<void> startListening({
required void Function(HardwareShortcutAction action) onAction,
}) async {
_onAction = onAction;
if (!_listening) {
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
_listening = true;
}
await loadFromBackend();
}
void stopListening() {
if (!_listening) return;
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
_listening = false;
}
Future<void> loadFromBackend() async {
final response = await _apiClient.dio.get('/user/shortcuts');
final body = response.data;
final data = body is Map ? body['data'] : body;
if (data is! List) return;
_bindings
..clear()
..addEntries(
data
.whereType<Map>()
.map((item) => _bindingFromJson(Map<String, dynamic>.from(item)))
.whereType<HardwareShortcutBinding>()
.map((binding) => MapEntry(binding.buttonCode, binding)),
);
}
void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) {
_captureCallback = onCapture;
}
bool _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final code = _buttonCode(event.logicalKey);
final name = event.logicalKey.keyLabel.isNotEmpty
? event.logicalKey.keyLabel
: event.logicalKey.debugName ?? 'Button $code';
final capture = _captureCallback;
if (capture != null) {
_captureCallback = null;
capture(code, name);
return true;
}
final binding = _bindings[code];
if (binding == null || !binding.enabled) return false;
_onAction?.call(binding.action);
return true;
}
int _buttonCode(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.audioVolumeUp) return 24;
if (key == LogicalKeyboardKey.audioVolumeDown) return 25;
return key.keyId & 0xFFFFFFFF;
}
}
HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
final action = _actionFromBackend(item['shortcutKey']?.toString());
final rawCode = item['buttonCode'];
final enabled = item['enabled'] != false;
final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
if (action == null || code == null || code <= 0) return null;
return HardwareShortcutBinding(
action: action,
buttonCode: code,
buttonName: item['buttonName']?.toString() ?? 'Button $code',
enabled: enabled,
);
}
HardwareShortcutAction? _actionFromBackend(String? key) {
switch (key) {
case 'CALL_GUARDIAN':
return HardwareShortcutAction.callGuardian;
case 'START_WALKGUIDE':
return HardwareShortcutAction.startWalkguide;
case 'STOP_WALKGUIDE':
return HardwareShortcutAction.stopWalkguide;
case 'SEND_SOS':
return HardwareShortcutAction.sendSos;
case 'OPEN_NOTIFICATION':
return HardwareShortcutAction.openNotification;
default:
return null;
}
}

View File

@ -1,8 +1,5 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../network/api_client.dart'; import '../network/api_client.dart';
import '../storage/local_database.dart';
class OfflineRequest { class OfflineRequest {
final String method; final String method;
@ -33,26 +30,35 @@ class OfflineRequest {
} }
class OfflineQueueService { class OfflineQueueService {
static const _key = 'offline_request_queue'; final LocalDatabase _database;
OfflineQueueService(this._database);
Future<void> enqueue(OfflineRequest request) async { Future<void> enqueue(OfflineRequest request) async {
final prefs = await SharedPreferences.getInstance(); await _database.offlineRequests.insert(OfflineRequestRecord(
final queue = await readAll(); method: request.method,
queue.add(request); path: request.path,
await prefs.setString(_key, jsonEncode(queue.map((e) => e.toJson()).toList())); body: request.body,
createdAt: request.createdAt,
));
} }
Future<List<OfflineRequest>> readAll() async { Future<List<OfflineRequest>> readAll() async {
final prefs = await SharedPreferences.getInstance(); final records = await _database.offlineRequests.getAll();
final raw = prefs.getString(_key); return records
if (raw == null || raw.isEmpty) return []; .map(
final decoded = jsonDecode(raw) as List<dynamic>; (record) => OfflineRequest(
return decoded.map((e) => OfflineRequest.fromJson(Map<String, dynamic>.from(e as Map))).toList(); method: record.method,
path: record.path,
body: record.body,
createdAt: record.createdAt,
),
)
.toList();
} }
Future<void> clear() async { Future<void> clear() async {
final prefs = await SharedPreferences.getInstance(); await _database.offlineRequests.clear();
await prefs.remove(_key);
} }
Future<int> syncPending(ApiClient apiClient) async { Future<int> syncPending(ApiClient apiClient) async {
@ -79,11 +85,21 @@ class OfflineQueueService {
} }
} }
final prefs = await SharedPreferences.getInstance();
if (remaining.isEmpty) { if (remaining.isEmpty) {
await prefs.remove(_key); await clear();
} else { } else {
await prefs.setString(_key, jsonEncode(remaining.map((e) => e.toJson()).toList())); await _database.offlineRequests.replaceAll(
remaining
.map(
(request) => OfflineRequestRecord(
method: request.method,
path: request.path,
body: request.body,
createdAt: request.createdAt,
),
)
.toList(),
);
} }
return synced; return synced;
} }

View File

@ -0,0 +1,51 @@
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
class LocalCacheStore {
LocalCacheStore._();
static final LocalCacheStore instance = LocalCacheStore._();
static const _table = 'walkguide_cache';
Database? _database;
Future<String?> get(String key) async {
final db = await _open();
final result = db.select(
'SELECT value FROM $_table WHERE cache_key = ? LIMIT 1',
[key],
);
if (result.isEmpty) return null;
return result.first['value'] as String?;
}
Future<void> set(String key, String value) async {
final db = await _open();
db.execute(
'INSERT INTO $_table(cache_key, value, updated_at) VALUES (?, ?, ?) '
'ON CONFLICT(cache_key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
[key, value, DateTime.now().toIso8601String()],
);
}
Future<void> remove(String key) async {
final db = await _open();
db.execute('DELETE FROM $_table WHERE cache_key = ?', [key]);
}
Future<Database> _open() async {
final existing = _database;
if (existing != null) return existing;
final directory = await getApplicationDocumentsDirectory();
final db = sqlite3.open('${directory.path}/walkguide_cache.sqlite');
db.execute('''
CREATE TABLE IF NOT EXISTS $_table (
cache_key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
_database = db;
return db;
}
}

View File

@ -0,0 +1,22 @@
import 'package:shared_preferences/shared_preferences.dart';
class LocalCacheStore {
LocalCacheStore._();
static final LocalCacheStore instance = LocalCacheStore._();
Future<String?> get(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}
Future<void> set(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
Future<void> remove(String key) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);
}
}

View File

@ -0,0 +1,239 @@
import 'dart:convert';
import 'local_cache_store_web.dart'
if (dart.library.io) 'local_cache_store_native.dart';
class CachedActivityLog {
final int? id;
final int? userId;
final String logType;
final String description;
final String? metadata;
final DateTime createdAt;
final bool synced;
const CachedActivityLog({
this.id,
this.userId,
required this.logType,
required this.description,
this.metadata,
required this.createdAt,
this.synced = false,
});
Map<String, dynamic> toJson() => {
'id': id,
'userId': userId,
'logType': logType,
'description': description,
'metadata': metadata,
'createdAt': createdAt.toIso8601String(),
'synced': synced,
};
factory CachedActivityLog.fromJson(Map<String, dynamic> json) {
return CachedActivityLog(
id: json['id'] as int?,
userId: json['userId'] as int?,
logType: json['logType'] as String,
description: json['description'] as String,
metadata: json['metadata'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
synced: json['synced'] == true,
);
}
}
class CachedObstacleLog {
final String label;
final String direction;
final String estimatedDistance;
final DateTime createdAt;
final bool synced;
const CachedObstacleLog({
required this.label,
required this.direction,
required this.estimatedDistance,
required this.createdAt,
this.synced = false,
});
Map<String, dynamic> toJson() => {
'label': label,
'direction': direction,
'estimatedDistance': estimatedDistance,
'createdAt': createdAt.toIso8601String(),
'synced': synced,
};
factory CachedObstacleLog.fromJson(Map<String, dynamic> json) {
return CachedObstacleLog(
label: json['label'] as String,
direction: json['direction'] as String,
estimatedDistance: json['estimatedDistance'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
synced: json['synced'] == true,
);
}
}
class CachedNotification {
final int? id;
final String notificationType;
final String? content;
final String? voiceNoteUrl;
final bool isRead;
final DateTime createdAt;
const CachedNotification({
this.id,
required this.notificationType,
this.content,
this.voiceNoteUrl,
required this.isRead,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'notificationType': notificationType,
'content': content,
'voiceNoteUrl': voiceNoteUrl,
'isRead': isRead,
'createdAt': createdAt.toIso8601String(),
};
factory CachedNotification.fromJson(Map<String, dynamic> json) {
return CachedNotification(
id: json['id'] as int?,
notificationType: json['notificationType'] as String,
content: json['content'] as String?,
voiceNoteUrl: json['voiceNoteUrl'] as String?,
isRead: json['isRead'] == true,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
class LocalDatabase {
final activityLogs = ActivityLogDao();
final obstacleLogs = ObstacleLogDao();
final notifications = NotificationDao();
final offlineRequests = OfflineRequestDao();
}
abstract class _JsonListDao<T> {
String get storageKey;
T fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson(T item);
Future<List<T>> getAll() async {
final raw = await LocalCacheStore.instance.get(storageKey);
if (raw == null || raw.isEmpty) return [];
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded
.map((item) => fromJson(Map<String, dynamic>.from(item as Map)))
.toList();
}
Future<void> replaceAll(List<T> items) async {
await LocalCacheStore.instance.set(
storageKey,
jsonEncode(items.map(toJson).toList()),
);
}
Future<void> insert(T item) async {
final items = await getAll();
items.add(item);
await replaceAll(items);
}
Future<void> clear() async {
await LocalCacheStore.instance.remove(storageKey);
}
}
class ActivityLogDao extends _JsonListDao<CachedActivityLog> {
@override
String get storageKey => 'cached_activity_logs';
@override
CachedActivityLog fromJson(Map<String, dynamic> json) {
return CachedActivityLog.fromJson(json);
}
@override
Map<String, dynamic> toJson(CachedActivityLog item) => item.toJson();
}
class ObstacleLogDao extends _JsonListDao<CachedObstacleLog> {
@override
String get storageKey => 'cached_obstacle_logs';
@override
CachedObstacleLog fromJson(Map<String, dynamic> json) {
return CachedObstacleLog.fromJson(json);
}
@override
Map<String, dynamic> toJson(CachedObstacleLog item) => item.toJson();
}
class NotificationDao extends _JsonListDao<CachedNotification> {
@override
String get storageKey => 'cached_notifications';
@override
CachedNotification fromJson(Map<String, dynamic> json) {
return CachedNotification.fromJson(json);
}
@override
Map<String, dynamic> toJson(CachedNotification item) => item.toJson();
}
class OfflineRequestRecord {
final String method;
final String path;
final Map<String, dynamic> body;
final DateTime createdAt;
const OfflineRequestRecord({
required this.method,
required this.path,
required this.body,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'method': method,
'path': path,
'body': body,
'createdAt': createdAt.toIso8601String(),
};
factory OfflineRequestRecord.fromJson(Map<String, dynamic> json) {
return OfflineRequestRecord(
method: json['method'] as String,
path: json['path'] as String,
body: Map<String, dynamic>.from(json['body'] as Map),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
class OfflineRequestDao extends _JsonListDao<OfflineRequestRecord> {
@override
String get storageKey => 'offline_request_queue';
@override
OfflineRequestRecord fromJson(Map<String, dynamic> json) {
return OfflineRequestRecord.fromJson(json);
}
@override
Map<String, dynamic> toJson(OfflineRequestRecord item) => item.toJson();
}

View File

@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
Future<T?> ignoreInitFailure<T>(
Future<T> Function() action, {
required String label,
}) async {
try {
return await action();
} catch (error) {
debugPrint('$label skipped: $error');
return null;
}
}

View File

@ -0,0 +1,18 @@
import 'dart:async';
Future<T?> guarded<T>(
Future<T> Function() action, {
void Function(Object error)? onError,
void Function()? onTimeout,
}) async {
try {
return await action();
} on TimeoutException catch (error) {
onTimeout?.call();
onError?.call(error);
return null;
} catch (error) {
onError?.call(error);
return null;
}
}

View File

@ -0,0 +1 @@
export '../../activity_log_screen.dart';

View File

@ -10,6 +10,7 @@ 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/constants/app_constants.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/utils/operation_guard.dart';
import '../../shared/widgets/feature_page.dart'; import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget { class AiBenchmarkScreen extends StatefulWidget {
@ -79,11 +80,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
notifWatch.stop(); notifWatch.stop();
final ttsWatch = Stopwatch()..start(); final ttsWatch = Stopwatch()..start();
try { await guarded<void>(
await sl<TtsService>() () => sl<TtsService>()
.speakImmediate(notificationText) .speakImmediate(notificationText)
.timeout(const Duration(seconds: 3)); .timeout(const Duration(seconds: 3)),
} catch (_) {} );
ttsWatch.stop(); ttsWatch.stop();
final run = { final run = {
@ -113,23 +114,27 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
Future<int> _measureCapture() async { Future<int> _measureCapture() async {
final watch = Stopwatch()..start(); final watch = Stopwatch()..start();
CameraController? controller; CameraController? controller;
try { await guarded<void>(
() async {
final cameras = final cameras =
await availableCameras().timeout(const Duration(seconds: 3)); await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) { if (cameras.isNotEmpty) {
controller = CameraController( final activeController = CameraController(
cameras.first, cameras.first,
ResolutionPreset.low, ResolutionPreset.low,
enableAudio: false, enableAudio: false,
); );
await controller.initialize().timeout(const Duration(seconds: 5)); controller = activeController;
await controller.takePicture().timeout(const Duration(seconds: 5)); await activeController.initialize().timeout(const Duration(seconds: 5));
await activeController.takePicture().timeout(const Duration(seconds: 5));
} }
} catch (_) { },
onError: (_) {},
);
if (controller == null) {
await Future<void>.delayed(const Duration(milliseconds: 16)); await Future<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
} }
await controller?.dispose();
watch.stop(); watch.stop();
return watch.elapsedMilliseconds; return watch.elapsedMilliseconds;
} }
@ -273,7 +278,8 @@ class _StatusBox extends StatelessWidget {
} }
Future<List<String>> _discoverTfliteModels() async { Future<List<String>> _discoverTfliteModels() async {
try { return await guarded<List<String>>(
() async {
final manifestRaw = await rootBundle.loadString('AssetManifest.json'); final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>; final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys final models = manifest.keys
@ -282,9 +288,9 @@ Future<List<String>> _discoverTfliteModels() async {
.toList() .toList()
..sort(); ..sort();
return models; return models;
} catch (_) { },
return const []; ) ??
} const [];
} }
String _two(int value) => value.toString().padLeft(2, '0'); String _two(int value) => value.toString().padLeft(2, '0');

View File

@ -0,0 +1 @@
export '../../ai_benchmark_screen.dart';

View File

@ -1,5 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/api_service.dart'; import '../../../../core/network/api_client.dart';
import 'auth_model.dart'; import 'auth_model.dart';
abstract class AuthRemoteDataSource { abstract class AuthRemoteDataSource {
@ -7,14 +7,14 @@ abstract class AuthRemoteDataSource {
} }
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiService apiService; final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiService); AuthRemoteDataSourceImpl(this.apiClient);
@override @override
Future<AuthModel> login(String email, String password) async { Future<AuthModel> login(String email, String password) async {
try { try {
final response = await apiService.post('/auth/login', { final response = await apiClient.dio.post('/auth/login', data: {
'email': email, 'email': email,
'password': password, 'password': password,
}); });

View File

@ -0,0 +1 @@
export '../../call_screen.dart';

View File

@ -9,6 +9,7 @@ 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/errors/friendly_error.dart';
import '../../../core/network/api_client.dart'; import '../../../core/network/api_client.dart';
import '../../../core/utils/operation_guard.dart';
Dio get _api => sl<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -46,7 +47,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
_error = null; _error = null;
_needsPairing = false; _needsPairing = false;
}); });
try { await guarded<void>(
() async {
final paired = await _hasActivePairing(); final paired = await _hasActivePairing();
if (!paired) { if (!paired) {
setState(() { setState(() {
@ -72,22 +74,20 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
}); });
} }
} on DioException catch (e) { },
setState(() { onError: (error) => setState(() {
_error = _error = error is DioException
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.'); ? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.')
}); : 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
} catch (e) { }),
setState( );
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.'); if (mounted) setState(() => _loading = false);
} finally {
if (mounted) setState(() => _loading = false);
}
} }
Future<void> _save() async { Future<void> _save() async {
setState(() => _saving = true); setState(() => _saving = true);
try { await guarded<void>(
() async {
await _api.put('/guardian/ai-config', data: { await _api.put('/guardian/ai-config', data: {
'confidenceThreshold': _confidenceThreshold, 'confidenceThreshold': _confidenceThreshold,
'alertDistanceClose': _alertDistanceClose, 'alertDistanceClose': _alertDistanceClose,
@ -103,39 +103,35 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
), ),
); );
} }
} on DioException catch (e) { },
if (mounted) { onError: (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(friendlyDioMessage(e, content: Text(error is DioException
fallback: 'Gagal menyimpan konfigurasi.')), ? friendlyDioMessage(error,
fallback: 'Gagal menyimpan konfigurasi.')
: 'Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: const Color(0xFFDC2626), backgroundColor: const Color(0xFFDC2626),
), ),
); );
} },
} catch (e) { );
if (mounted) { if (mounted) setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: Color(0xFFDC2626),
),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
} }
Future<bool> _hasActivePairing() async { Future<bool> _hasActivePairing() async {
try { return await guarded<bool>(
() 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; },
) ??
false;
} }
@override @override

View File

@ -1,4 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
@ -15,29 +20,54 @@ class GuardianSendNotifScreen extends StatefulWidget {
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> { class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
final _message = TextEditingController(); final _message = TextEditingController();
final _recorder = AudioRecorder();
bool _loading = false; bool _loading = false;
bool _recording = false;
bool _voiceMode = false;
String? _voicePath;
DateTime? _recordStart;
int _voiceDuration = 0;
@override @override
void dispose() { void dispose() {
_message.dispose(); _message.dispose();
_recorder.dispose();
super.dispose(); super.dispose();
} }
Future<void> _send() async { Future<void> _send() async {
final message = _message.text.trim(); final message = _message.text.trim();
if (message.isEmpty) { if (!_voiceMode && message.isEmpty) {
_snack('Tulis pesan dulu.'); _snack('Tulis pesan dulu.');
return; return;
} }
if (_voiceMode && _voicePath == null) {
_snack('Rekam voice note dulu.');
return;
}
setState(() => _loading = true); setState(() => _loading = true);
await runFriendlyAction( await runFriendlyAction(
() async { () async {
final data = _voiceMode
? {
'notifType': 'VOICE_NOTE',
'content': await _voiceAsDataUrl(),
'voiceNoteUrl': 'inline-audio',
'voiceNoteDuration': _voiceDuration,
}
: {
'notifType': 'TEXT',
'content': message,
};
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: { await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
'notifType': 'TEXT', ...data,
'content': message, }).timeout(const Duration(seconds: 12));
}).timeout(const Duration(seconds: 8));
_message.clear(); _message.clear();
_snack('Notifikasi terkirim ke User.'); _voicePath = null;
_voiceDuration = 0;
_snack(_voiceMode
? 'Voice message terkirim ke User.'
: 'Notifikasi terkirim ke User.');
}, },
onError: _snack, onError: _snack,
fallback: 'Gagal mengirim notifikasi. Coba lagi.', fallback: 'Gagal mengirim notifikasi. Coba lagi.',
@ -45,6 +75,50 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
Future<void> _toggleRecording() async {
if (_recording) {
final path = await _recorder.stop();
final duration = _recordStart == null
? 0
: DateTime.now().difference(_recordStart!).inSeconds;
if (!mounted) return;
setState(() {
_recording = false;
_voicePath = path;
_voiceDuration = duration.clamp(1, 600).toInt();
_recordStart = null;
});
return;
}
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) {
_snack('Izin mikrofon dibutuhkan untuk rekam voice message.');
return;
}
final dir = await getTemporaryDirectory();
final path =
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: path,
);
if (!mounted) return;
setState(() {
_recording = true;
_recordStart = DateTime.now();
_voicePath = null;
_voiceDuration = 0;
});
}
Future<String> _voiceAsDataUrl() async {
final path = _voicePath;
if (path == null) return '';
final bytes = await File(path).readAsBytes();
return 'data:audio/mp4;base64,${base64Encode(bytes)}';
}
void _snack(String message) { void _snack(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@ -75,10 +149,29 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.message_outlined),
label: Text('Text'),
),
ButtonSegment(
value: true,
icon: Icon(Icons.mic_none_outlined),
label: Text('Voice'),
),
],
selected: {_voiceMode},
onSelectionChanged: _loading || _recording
? null
: (value) => setState(() => _voiceMode = value.first),
),
const SizedBox(height: 14),
TextField( TextField(
controller: _message, controller: _message,
minLines: 5, minLines: _voiceMode ? 2 : 5,
maxLines: 8, maxLines: _voiceMode ? 3 : 8,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Message', labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.', hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
@ -86,6 +179,68 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
alignLabelWithHint: true, alignLabelWithHint: true,
), ),
), ),
if (_voiceMode) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: _recording
? const Color(0xFFFEE2E2)
: const Color(0xFFEFF6FF),
child: Icon(
_recording ? Icons.graphic_eq : Icons.mic,
color: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_recording
? 'Recording... tap stop when done'
: _voicePath == null
? 'No voice note recorded'
: 'Voice note ready',
style: const TextStyle(
fontWeight: FontWeight.w800),
),
Text(
_recording
? 'Speak clearly near the microphone'
: _voicePath == null
? 'Record a short message for User'
: '${_voiceDuration}s audio attached',
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12),
),
],
),
),
FilledButton.icon(
onPressed: _loading ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
label: Text(_recording ? 'Stop' : 'Record'),
style: FilledButton.styleFrom(
backgroundColor: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
],
),
),
],
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton.icon( FilledButton.icon(
onPressed: _loading ? null : _send, onPressed: _loading ? null : _send,
@ -96,7 +251,11 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.send), : const Icon(Icons.send),
label: Text(_loading ? 'Sending...' : 'Send Message'), label: Text(_loading
? 'Sending...'
: _voiceMode
? 'Send Voice Message'
: 'Send Message'),
), ),
], ],
), ),

View File

@ -63,6 +63,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
bool _loading = true; bool _loading = true;
String? _error; String? _error;
List<Map<String, dynamic>> _items = const []; List<Map<String, dynamic>> _items = const [];
bool get _isVoiceCommands => widget.endpoint.contains('voice-commands');
bool get _isShortcuts => widget.endpoint.contains('shortcuts');
@override @override
void initState() { void initState() {
@ -131,28 +133,188 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
itemBuilder: (_, index) => _EndpointCard( itemBuilder: (_, index) => _EndpointCard(
icon: widget.icon, icon: widget.icon,
item: _items[index], item: _items[index],
editable: _isVoiceCommands || _isShortcuts,
onEdit: () => _editItem(_items[index]),
), ),
), ),
), ),
); );
} }
Future<void> _editItem(Map<String, dynamic> item) async {
if (_isVoiceCommands) {
await _editVoiceCommand(item);
} else if (_isShortcuts) {
await _editShortcut(item);
}
}
Future<void> _editVoiceCommand(Map<String, dynamic> item) async {
final phrase = TextEditingController(
text: item['triggerPhrase']?.toString() ?? '',
);
var enabled = item['enabled'] != false;
final saved = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: phrase,
decoration: const InputDecoration(
labelText: 'Trigger phrase',
prefixIcon: Icon(Icons.record_voice_over_outlined),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled,
onChanged: (value) => setDialogState(() => enabled = value),
title: const Text('Enabled'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
],
),
),
);
if (saved != true) return;
await _submitUpdate({
'commandKey': item['commandKey'],
'triggerPhrase': phrase.text.trim(),
'enabled': enabled,
});
}
Future<void> _editShortcut(Map<String, dynamic> item) async {
final buttonName = TextEditingController(
text: item['buttonName']?.toString() ?? '',
);
final buttonCode = TextEditingController(
text: item['buttonCode']?.toString() ?? '',
);
var enabled = item['enabled'] != false;
final saved = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
'Shortcut'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: buttonName,
decoration: const InputDecoration(
labelText: 'Button name',
prefixIcon: Icon(Icons.touch_app_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: buttonCode,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Android key code',
prefixIcon: Icon(Icons.numbers_outlined),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled,
onChanged: (value) => setDialogState(() => enabled = value),
title: const Text('Enabled'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
],
),
),
);
if (saved != true) return;
await _submitUpdate({
'shortcutKey': item['shortcutKey'],
'buttonName': buttonName.text.trim(),
'buttonCode': int.tryParse(buttonCode.text.trim()),
'enabled': enabled,
});
}
Future<void> _submitUpdate(Map<String, dynamic> payload) async {
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.put(widget.endpoint, data: payload)
.timeout(const Duration(seconds: 8));
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Konfigurasi berhasil disimpan.')),
);
},
onError: (message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
},
fallback: 'Konfigurasi belum bisa disimpan.',
);
}
} }
class _EndpointCard extends StatelessWidget { class _EndpointCard extends StatelessWidget {
final IconData icon; final IconData icon;
final Map<String, dynamic> item; final Map<String, dynamic> item;
final bool editable;
final VoidCallback? onEdit;
const _EndpointCard({required this.icon, required this.item}); const _EndpointCard({
required this.icon,
required this.item,
this.editable = false,
this.onEdit,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = _firstText(item, ['name', 'command', 'label', 'type']) ?? final title = _labelFromKey(
_firstText(item, ['commandKey', 'shortcutKey', 'name', 'command']) ??
'',
) ??
'Item #${item['id'] ?? '-'}'; 'Item #${item['id'] ?? '-'}';
final subtitle = _firstText( final subtitle = _firstText(
item, item,
['description', 'action', 'shortcut', 'status', 'createdAt'], [
'triggerPhrase',
'buttonName',
'description',
'action',
'shortcut',
'status',
'createdAt'
],
) ?? ) ??
'Data aktif'; 'Data aktif';
final enabled = item['enabled'] != false;
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -181,15 +343,65 @@ class _EndpointCard extends StatelessWidget {
const SizedBox(height: 3), const SizedBox(height: 3),
Text(subtitle, Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))), style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
_SmallPill(
label: enabled ? 'Enabled' : 'Disabled',
color: enabled
? const Color(0xFF16A34A)
: const Color(0xFF94A3B8),
),
if (item['buttonCode'] != null)
_SmallPill(
label: 'Key ${item['buttonCode']}',
color: const Color(0xFF2563EB),
),
],
),
], ],
), ),
), ),
if (editable)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit',
),
], ],
), ),
); );
} }
} }
class _SmallPill extends StatelessWidget {
final String label;
final Color color;
const _SmallPill({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
);
}
}
String? _firstText(Map<String, dynamic> item, List<String> keys) { String? _firstText(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = item[key]?.toString().trim(); final value = item[key]?.toString().trim();
@ -197,3 +409,13 @@ String? _firstText(Map<String, dynamic> item, List<String> keys) {
} }
return null; return null;
} }
String? _labelFromKey(String value) {
if (value.trim().isEmpty) return null;
return value
.split('_')
.where((part) => part.isNotEmpty)
.map((part) =>
part[0].toUpperCase() + part.substring(1).toLowerCase())
.join(' ');
}

View File

@ -0,0 +1 @@
export '../../guardian_activity_log_screen.dart';

View File

@ -0,0 +1 @@
export '../../guardian_ai_config_screen.dart';

View File

@ -0,0 +1 @@
export '../../guardian_map_screen.dart';

View File

@ -0,0 +1 @@
export '../../guardian_send_notification_screen.dart';

View File

@ -0,0 +1 @@
export '../../guardian_settings_screen.dart';

View File

@ -0,0 +1 @@
export '../../guardian_tools_screen.dart';

View File

@ -13,6 +13,7 @@ import '../../../app/injection_container.dart';
import '../../../core/network/api_client.dart'; import '../../../core/network/api_client.dart';
import '../../../core/services/websocket_service.dart'; import '../../../core/services/websocket_service.dart';
import '../../../core/storage/secure_storage.dart'; import '../../../core/storage/secure_storage.dart';
import '../../../core/utils/operation_guard.dart';
// //
// GUARDIAN DASHBOARD SCREEN // GUARDIAN DASHBOARD SCREEN
@ -57,6 +58,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
); );
bool _sosAlert = false; bool _sosAlert = false;
List<Map<String, dynamic>> _pendingSos = const [];
// Refresh button animation // Refresh button animation
late final AnimationController _refreshCtrl = AnimationController( late final AnimationController _refreshCtrl = AnimationController(
@ -89,7 +91,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
_error = null; _error = null;
}); });
} }
try { await guarded<void>(
() async {
_guardianName = _guardianName =
await sl<SecureStorage>().getDisplayName() ?? 'Guardian'; await sl<SecureStorage>().getDisplayName() ?? 'Guardian';
@ -103,7 +106,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
final dashboard = results[0] as Map<String, dynamic>?; final dashboard = results[0] as Map<String, dynamic>?;
final activityList = final activityList =
results[1] as List<Map<String, dynamic>>; results[1] as List<Map<String, dynamic>>;
final sosPending = results[2] as int; final sosPendingEvents = results[2] as List<Map<String, dynamic>>;
final sosPending = sosPendingEvents.length;
// Extract latest GPS from dashboard // Extract latest GPS from dashboard
final lastLoc = dashboard?['lastLocation'] as Map<String, dynamic>?; final lastLoc = dashboard?['lastLocation'] as Map<String, dynamic>?;
@ -150,6 +154,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
recentActivity: activityList, recentActivity: activityList,
isPaired: userStatus != null || dashboard != null, isPaired: userStatus != null || dashboard != null,
); );
_pendingSos = sosPendingEvents;
if (newLatLng != null) { if (newLatLng != null) {
_liveLatLng = newLatLng; _liveLatLng = newLatLng;
} }
@ -163,32 +168,31 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
// Move map to latest location // Move map to latest location
if (newLatLng != null) { if (newLatLng != null) {
try { _moveMapSafely(newLatLng);
_mapController.move(newLatLng, 15);
} catch (_) {}
} }
} catch (e) { },
setState(() { onError: (e) => setState(() {
_loading = false; _loading = false;
_error = _friendlyError(e); _error = _friendlyError(e);
}); }),
} );
} }
Future<Map<String, dynamic>?> _fetchDashboard() async { Future<Map<String, dynamic>?> _fetchDashboard() async {
try { return await guarded<Map<String, dynamic>?>(
() async {
final res = await _api final res = await _api
.get('/guardian/dashboard') .get('/guardian/dashboard')
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
final d = res.data['data']; final d = res.data['data'];
return d is Map ? Map<String, dynamic>.from(d) : null; return d is Map ? Map<String, dynamic>.from(d) : null;
} catch (_) { },
return null; );
}
} }
Future<List<Map<String, dynamic>>> _fetchActivity() async { Future<List<Map<String, dynamic>>> _fetchActivity() async {
try { return await guarded<List<Map<String, dynamic>>>(
() async {
final res = await _api final res = await _api
.get('/guardian/activity-logs', .get('/guardian/activity-logs',
queryParameters: {'size': 5, 'page': 0}) queryParameters: {'size': 5, 'page': 0})
@ -202,12 +206,15 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
.map((e) => Map<String, dynamic>.from(e)) .map((e) => Map<String, dynamic>.from(e))
.toList(); .toList();
} }
} catch (_) {} return const [];
return const []; },
) ??
const [];
} }
Future<int> _fetchSosPending() async { Future<List<Map<String, dynamic>>> _fetchSosPending() async {
try { return await guarded<List<Map<String, dynamic>>>(
() async {
final res = await _api final res = await _api
.get('/guardian/sos-events', .get('/guardian/sos-events',
queryParameters: {'size': 10, 'page': 0}) queryParameters: {'size': 10, 'page': 0})
@ -219,17 +226,21 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
return content return content
.whereType<Map>() .whereType<Map>()
.where((e) => e['status'] == 'TRIGGERED') .where((e) => e['status'] == 'TRIGGERED')
.length; .map((e) => Map<String, dynamic>.from(e))
.toList();
} }
} catch (_) {} return const [];
return 0; },
) ??
const [];
} }
// WebSocket subscription // WebSocket subscription
void _subscribeWebSocket() { void _subscribeWebSocket() {
final ws = sl<WebSocketService>(); final ws = sl<WebSocketService>();
Future.microtask(() async { Future.microtask(() async {
try { await guarded<void>(
() async {
final userId = await _getLinkedUserId(); final userId = await _getLinkedUserId();
if (userId == null) return; if (userId == null) return;
ws.subscribeLocation(userId, (lat, lng) { ws.subscribeLocation(userId, (lat, lng) {
@ -239,26 +250,30 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
_liveLatLng = newPos; _liveLatLng = newPos;
_liveConnected = true; _liveConnected = true;
}); });
try { _moveMapSafely(newPos);
_mapController.move(newPos, 15);
} catch (_) {}
}); });
ws.subscribeSos((sosData) { ws.subscribeSos((sosData) {
if (!mounted) return; if (!mounted) return;
_triggerSosFlash(); _triggerSosFlash();
setState(() { setState(() {
_pendingSos = [
Map<String, dynamic>.from(sosData),
..._pendingSos,
];
_data = _data?.copyWith( _data = _data?.copyWith(
unreadSos: (_data?.unreadSos ?? 0) + 1); unreadSos: (_data?.unreadSos ?? 0) + 1);
}); });
_showSosSnackbar(sosData); _showSosSnackbar(sosData);
}); });
if (mounted) setState(() => _liveConnected = true); if (mounted) setState(() => _liveConnected = true);
} catch (_) {} },
);
}); });
} }
Future<String?> _getLinkedUserId() async { Future<String?> _getLinkedUserId() async {
try { return await guarded<String?>(
() 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));
@ -267,8 +282,9 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
return d['pairedWithId']?.toString() ?? return d['pairedWithId']?.toString() ??
d['userId']?.toString(); d['userId']?.toString();
} }
} catch (_) {} return null;
return null; },
);
} }
void _triggerSosFlash() { void _triggerSosFlash() {
@ -303,14 +319,56 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
), ),
]), ]),
action: SnackBarAction( action: SnackBarAction(
label: 'Lihat', label: 'Tangani',
textColor: Colors.white, textColor: Colors.white,
onPressed: () => context.go('/guardian/logs'), onPressed: _handleLatestSos,
), ),
), ),
); );
} }
Future<void> _handleLatestSos() async {
final sosId = _pendingSos
.map((e) => e['id'])
.where((id) => id != null)
.map((id) => int.tryParse(id.toString()))
.firstWhere((id) => id != null, orElse: () => null);
if (sosId == null) {
await _loadAll(silent: true);
return;
}
await guarded<void>(
() async {
await _api
.put('/guardian/sos/$sosId/resolve')
.timeout(const Duration(seconds: 8));
if (!mounted) return;
setState(() {
_pendingSos =
_pendingSos.where((e) => e['id']?.toString() != '$sosId').toList();
_data = _data?.copyWith(unreadSos: _pendingSos.length);
if (_pendingSos.isEmpty) {
_sosAlert = false;
_sosCtrl.stop();
}
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('SOS ditandai sudah ditangani.')),
);
},
onError: (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')),
);
},
);
}
void _moveMapSafely(LatLng position) {
guarded<void>(() async => _mapController.move(position, 15));
}
Future<void> _refresh() async { Future<void> _refresh() async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
_refreshCtrl.forward(from: 0); _refreshCtrl.forward(from: 0);
@ -517,10 +575,10 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
), ),
), ),
TextButton( TextButton(
onPressed: () => context.go('/guardian/logs'), onPressed: _handleLatestSos,
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: Colors.white), foregroundColor: Colors.white),
child: const Text('Tangani'), child: const Text('Handle'),
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {

View File

@ -16,6 +16,7 @@ import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.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/utils/operation_guard.dart';
// helpers // helpers
@ -70,7 +71,8 @@ class _NavState extends Cubit<int> {
// locate // locate
Future<bool> locate() async { Future<bool> locate() async {
_set(_NavPhase.locating, 'Mencari lokasi GPS…'); _set(_NavPhase.locating, 'Mencari lokasi GPS…');
try { final located = await guarded<bool>(
() async {
LocationPermission perm = await Geolocator.checkPermission(); LocationPermission perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) { if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission(); perm = await Geolocator.requestPermission();
@ -86,14 +88,12 @@ class _NavState extends Cubit<int> {
_set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.'); _set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.');
_reportToBackend(pos); _reportToBackend(pos);
return true; return true;
} on TimeoutException { },
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'); onTimeout: () => _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'),
return false; onError: (_) => _set(_NavPhase.error,
} catch (e) { 'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'),
_set(_NavPhase.error, );
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'); return located ?? false;
return false;
}
} }
void _reportToBackend(Position pos) { void _reportToBackend(Position pos) {
@ -112,7 +112,8 @@ class _NavState extends Cubit<int> {
// search Nominatim // search Nominatim
Future<List<_Place>> searchPlaces(String query) async { Future<List<_Place>> searchPlaces(String query) async {
if (query.trim().isEmpty) return const []; if (query.trim().isEmpty) return const [];
try { return await guarded<List<_Place>>(
() async {
final res = await Dio().get( final res = await Dio().get(
'https://nominatim.openstreetmap.org/search', 'https://nominatim.openstreetmap.org/search',
queryParameters: { queryParameters: {
@ -137,9 +138,8 @@ class _NavState extends Cubit<int> {
position: LatLng(lat, lng), position: LatLng(lat, lng),
); );
}).toList(); }).toList();
} catch (_) { },
return const []; ) ?? const [];
}
} }
String _viewbox(LatLng c) => String _viewbox(LatLng c) =>
@ -147,7 +147,8 @@ class _NavState extends Cubit<int> {
// reverse geocode // reverse geocode
Future<String> reverseGeocode(LatLng pos) async { Future<String> reverseGeocode(LatLng pos) async {
try { return await guarded<String>(
() async {
final res = await Dio().get( final res = await Dio().get(
'https://nominatim.openstreetmap.org/reverse', 'https://nominatim.openstreetmap.org/reverse',
queryParameters: { queryParameters: {
@ -162,9 +163,9 @@ class _NavState extends Cubit<int> {
); );
return res.data['display_name']?.toString() ?? return res.data['display_name']?.toString() ??
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
} catch (_) { },
return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; ) ??
} '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
} }
// OSRM routing // OSRM routing
@ -177,7 +178,8 @@ class _NavState extends Cubit<int> {
_set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}'); _set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}');
final origin = currentPosition!; final origin = currentPosition!;
try { await guarded<void>(
() async {
final url = 'http://router.project-osrm.org/route/v1/foot/' final url = 'http://router.project-osrm.org/route/v1/foot/'
'${origin.longitude},${origin.latitude};' '${origin.longitude},${origin.latitude};'
'${dest.position.longitude},${dest.position.latitude}' '${dest.position.longitude},${dest.position.latitude}'
@ -219,10 +221,10 @@ class _NavState extends Cubit<int> {
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.'); steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
_notify(); _notify();
_startTracking(); _startTracking();
} catch (e) { },
_set(_NavPhase.error, onError: (_) => _set(_NavPhase.error,
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'); 'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'),
} );
} }
String _shortName(_Place p) { String _shortName(_Place p) {

View File

@ -0,0 +1 @@
export '../../navigation_mode_screen.dart';

View File

@ -0,0 +1,91 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../domain/entities/guardian_notification.dart';
import '../domain/repositories/notification_repository.dart';
class NotificationState {
final bool loading;
final List<GuardianNotificationEntity> items;
final String? error;
final bool markingAll;
const NotificationState({
this.loading = false,
this.items = const [],
this.error,
this.markingAll = false,
});
NotificationState copyWith({
bool? loading,
List<GuardianNotificationEntity>? items,
String? error,
bool? markingAll,
}) {
return NotificationState(
loading: loading ?? this.loading,
items: items ?? this.items,
error: error,
markingAll: markingAll ?? this.markingAll,
);
}
}
class NotificationCubit extends Cubit<NotificationState> {
final NotificationRepository _repository;
NotificationCubit(this._repository) : super(const NotificationState());
Future<void> load() async {
emit(const NotificationState(loading: true));
final result = await _repository.getNotifications();
result.fold(
(failure) => emit(NotificationState(error: failure.message)),
(items) => emit(NotificationState(items: items)),
);
}
Future<void> markOneRead(int id) async {
final result = await _repository.markOneRead(id);
result.fold(
(failure) => emit(state.copyWith(error: failure.message)),
(_) => emit(state.copyWith(
items: state.items
.map((item) => item.id == id
? GuardianNotificationEntity(
id: item.id,
notificationType: item.notificationType,
content: item.content,
voiceNoteUrl: item.voiceNoteUrl,
voiceNoteDuration: item.voiceNoteDuration,
isRead: true,
createdAt: item.createdAt,
)
: item)
.toList(),
)),
);
}
Future<void> markAllRead() async {
emit(state.copyWith(markingAll: true));
final result = await _repository.markAllRead();
result.fold(
(failure) => emit(state.copyWith(error: failure.message, markingAll: false)),
(_) => emit(state.copyWith(
markingAll: false,
items: state.items
.map((item) => GuardianNotificationEntity(
id: item.id,
notificationType: item.notificationType,
content: item.content,
voiceNoteUrl: item.voiceNoteUrl,
voiceNoteDuration: item.voiceNoteDuration,
isRead: true,
createdAt: item.createdAt,
))
.toList(),
)),
);
}
}

View File

@ -0,0 +1,90 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/storage/local_database.dart';
import '../../domain/entities/guardian_notification.dart';
import '../../domain/repositories/notification_repository.dart';
class NotificationRepositoryImpl implements NotificationRepository {
final ApiClient _apiClient;
final LocalDatabase _database;
const NotificationRepositoryImpl(this._apiClient, this._database);
@override
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications() async {
try {
final response = await _apiClient.dio.get('/user/notifications');
final body = response.data;
final data = body is Map ? body['data'] : body;
final rawList = data is Map ? data['content'] : data;
final list = rawList is List
? rawList
.whereType<Map>()
.map((item) => _fromJson(Map<String, dynamic>.from(item)))
.toList()
: <GuardianNotificationEntity>[];
await _database.notifications.replaceAll(
list
.map((item) => CachedNotification(
id: item.id,
notificationType: item.notificationType,
content: item.content,
voiceNoteUrl: item.voiceNoteUrl,
isRead: item.isRead,
createdAt: item.createdAt ?? DateTime.now(),
))
.toList(),
);
return Right(list);
} catch (_) {
final cached = await _database.notifications.getAll();
return Right(
cached
.map((item) => GuardianNotificationEntity(
id: item.id,
notificationType: item.notificationType,
content: item.content,
voiceNoteUrl: item.voiceNoteUrl,
isRead: item.isRead,
createdAt: item.createdAt,
))
.toList(),
);
}
}
@override
Future<Either<Failure, void>> markAllRead() async {
try {
await _apiClient.dio.put('/user/notifications/mark-all-read');
return const Right(null);
} catch (_) {
return const Left(NetworkFailure('Gagal menandai semua notifikasi.'));
}
}
@override
Future<Either<Failure, void>> markOneRead(int id) async {
try {
await _apiClient.dio.put('/user/notifications/$id/read');
return const Right(null);
} catch (_) {
return const Left(NetworkFailure('Gagal menandai notifikasi.'));
}
}
}
GuardianNotificationEntity _fromJson(Map<String, dynamic> json) {
return GuardianNotificationEntity(
id: (json['id'] as num?)?.toInt(),
notificationType:
json['notifType']?.toString() ?? json['notificationType']?.toString() ?? 'TEXT',
content: json['content']?.toString(),
voiceNoteUrl: json['voiceNoteUrl']?.toString(),
voiceNoteDuration: (json['voiceNoteDuration'] as num?)?.toInt(),
isRead: json['isRead'] == true,
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
);
}

View File

@ -0,0 +1,19 @@
class GuardianNotificationEntity {
final int? id;
final String notificationType;
final String? content;
final String? voiceNoteUrl;
final int? voiceNoteDuration;
final bool isRead;
final DateTime? createdAt;
const GuardianNotificationEntity({
this.id,
required this.notificationType,
this.content,
this.voiceNoteUrl,
this.voiceNoteDuration,
this.isRead = false,
this.createdAt,
});
}

View File

@ -0,0 +1,10 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/guardian_notification.dart';
abstract class NotificationRepository {
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications();
Future<Either<Failure, void>> markAllRead();
Future<Either<Failure, void>> markOneRead(int id);
}

View File

@ -2,16 +2,19 @@
// ignore_for_file: use_build_context_synchronously // ignore_for_file: use_build_context_synchronously
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.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';
import 'application/notification_cubit.dart';
Dio get _api => sl<ApiClient>().dio; import 'domain/entities/guardian_notification.dart';
class NotificationScreen extends StatefulWidget { class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key}); const NotificationScreen({super.key});
@ -21,58 +24,31 @@ class NotificationScreen extends StatefulWidget {
} }
class _NotificationScreenState extends State<NotificationScreen> { class _NotificationScreenState extends State<NotificationScreen> {
List<_NotifItem> _items = []; late final NotificationCubit _notificationCubit;
bool _loading = true; final AudioPlayer _audioPlayer = AudioPlayer();
String? _error;
bool _markingAll = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_notificationCubit = sl<NotificationCubit>();
_load(); _load();
} }
Future<void> _load() async { @override
setState(() { void dispose() {
_loading = true; _audioPlayer.dispose();
_error = null; _notificationCubit.close();
}); super.dispose();
await runFriendlyAction(
() async {
final res = await _api
.get('/user/notifications')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
setState(() {
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
});
},
onError: (message) => setState(() => _error = message),
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
} }
List<Map<String, dynamic>> _extractList(dynamic responseBody) { Future<void> _load() async {
final data = responseBody is Map ? responseBody['data'] : null; await _notificationCubit.load();
final rawList = data is Map ? data['content'] : data;
if (rawList is! List) return const [];
return rawList
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
} }
Future<void> _markRead(int id) async { Future<void> _markRead(int id) async {
await runFriendlyAction( await runFriendlyAction(
() async { () async {
await _api await _notificationCubit.markOneRead(id);
.put('/user/notifications/$id/read')
.timeout(const Duration(seconds: 6));
setState(() {
final idx = _items.indexWhere((n) => n.id == id);
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
});
}, },
onError: (_) {}, onError: (_) {},
fallback: 'Gagal menandai notifikasi.', fallback: 'Gagal menandai notifikasi.',
@ -80,45 +56,80 @@ class _NotificationScreenState extends State<NotificationScreen> {
} }
Future<void> _markAllRead() async { Future<void> _markAllRead() async {
setState(() => _markingAll = true);
await runFriendlyAction( await runFriendlyAction(
() async { () async {
await _api await _notificationCubit.markAllRead();
.put('/user/notifications/mark-all-read')
.timeout(const Duration(seconds: 8));
setState(() {
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
});
_snack('Semua notifikasi ditandai sudah dibaca.'); _snack('Semua notifikasi ditandai sudah dibaca.');
}, },
onError: _snack, onError: _snack,
fallback: 'Gagal menandai semua dibaca.', fallback: 'Gagal menandai semua dibaca.',
); );
if (mounted) setState(() => _markingAll = false);
} }
Future<void> _readAloud(_NotifItem notif) async { Future<void> _readAloud(_NotifItem notif) async {
final tts = sl<TtsService>(); if (notif.type == 'VOICE_NOTE') {
tts.speak(notif.content ?? 'Voice note dari Guardian.'); final source = notif.voiceNoteUrl == 'inline-audio'
? notif.content
: notif.voiceNoteUrl;
if (source == null || source.isEmpty) {
_snack('Voice note kosong atau belum tersedia.');
return;
}
await _playVoiceNote(source);
} else {
final tts = sl<TtsService>();
tts.speak(notif.content ?? 'Pesan dari Guardian.');
}
await _markRead(notif.id); await _markRead(notif.id);
} }
Future<void> _playVoiceNote(String source) async {
await runFriendlyAction(
() async {
String? localPath;
if (source.startsWith('data:audio')) {
final comma = source.indexOf(',');
if (comma == -1) {
throw const FormatException('Invalid inline audio payload');
}
final bytes = base64Decode(source.substring(comma + 1));
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a');
await file.writeAsBytes(bytes, flush: true);
localPath = file.path;
}
if (localPath != null) {
await _audioPlayer.setFilePath(localPath);
} else {
await _audioPlayer.setUrl(source);
}
await _audioPlayer.play();
},
onError: _snack,
fallback: 'Voice note belum bisa diputar.',
);
}
void _snack(String msg) { void _snack(String msg) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} }
} }
int get _unreadCount => _items.where((n) => !n.isRead).length;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return BlocBuilder<NotificationCubit, NotificationState>(
child: Padding( bloc: _notificationCubit,
padding: const EdgeInsets.all(16), builder: (context, state) {
child: Column( final items = state.items.map(_NotifItem.fromEntity).toList();
crossAxisAlignment: CrossAxisAlignment.start, final unreadCount = items.where((n) => !n.isRead).length;
children: [ return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header // Header
Row( Row(
children: [ children: [
@ -135,9 +146,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
.headlineSmall .headlineSmall
?.copyWith(fontWeight: FontWeight.w800), ?.copyWith(fontWeight: FontWeight.w800),
), ),
if (_unreadCount > 0) ...[ if (unreadCount > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
_UnreadBadge(count: _unreadCount), _UnreadBadge(count: unreadCount),
], ],
], ],
), ),
@ -148,10 +159,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
], ],
), ),
), ),
if (_unreadCount > 0) if (unreadCount > 0)
TextButton.icon( TextButton.icon(
onPressed: _markingAll ? null : _markAllRead, onPressed: state.markingAll ? null : _markAllRead,
icon: _markingAll icon: state.markingAll
? const SizedBox( ? const SizedBox(
width: 14, width: 14,
height: 14, height: 14,
@ -170,29 +181,31 @@ class _NotificationScreenState extends State<NotificationScreen> {
// Body // Body
Expanded( Expanded(
child: _loading child: state.loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : state.error != null
? _ErrorPanel(message: _error!, onRetry: _load) ? _ErrorPanel(message: state.error!, onRetry: _load)
: _items.isEmpty : items.isEmpty
? const _EmptyPanel() ? const _EmptyPanel()
: RefreshIndicator( : RefreshIndicator(
onRefresh: _load, onRefresh: _load,
child: ListView.separated( child: ListView.separated(
itemCount: _items.length, itemCount: items.length,
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
const SizedBox(height: 10), const SizedBox(height: 10),
itemBuilder: (ctx, i) => _NotifCard( itemBuilder: (ctx, i) => _NotifCard(
notif: _items[i], notif: items[i],
onMarkRead: () => _markRead(_items[i].id), onMarkRead: () => _markRead(items[i].id),
onReadAloud: () => _readAloud(_items[i]), onReadAloud: () => _readAloud(items[i]),
), ),
), ),
), ),
), ),
], ],
), ),
), ),
);
},
); );
} }
} }
@ -216,14 +229,14 @@ class _NotifItem {
required this.createdAt, required this.createdAt,
}); });
factory _NotifItem.fromJson(Map<String, dynamic> j) => _NotifItem( factory _NotifItem.fromEntity(GuardianNotificationEntity entity) =>
id: j['id'] as int, _NotifItem(
type: j['notifType']?.toString() ?? 'TEXT', id: entity.id ?? 0,
content: j['content']?.toString(), type: entity.notificationType,
voiceNoteUrl: j['voiceNoteUrl']?.toString(), content: entity.content,
isRead: j['isRead'] == true, voiceNoteUrl: entity.voiceNoteUrl,
createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ?? isRead: entity.isRead,
DateTime.now(), createdAt: entity.createdAt ?? DateTime.now(),
); );
_NotifItem copyWith({bool? isRead}) => _NotifItem( _NotifItem copyWith({bool? isRead}) => _NotifItem(
@ -329,7 +342,9 @@ class _NotifCard extends StatelessWidget {
), ),
], ],
), ),
if (notif.content != null && notif.content!.isNotEmpty) ...[ if (!isVoice &&
notif.content != null &&
notif.content!.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
notif.content!, notif.content!,
@ -342,8 +357,10 @@ class _NotifCard extends StatelessWidget {
// Read aloud button // Read aloud button
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onReadAloud, onPressed: onReadAloud,
icon: const Icon(Icons.volume_up, size: 16), icon: Icon(isVoice ? Icons.play_arrow : Icons.volume_up,
label: const Text('Bacakan', style: TextStyle(fontSize: 13)), size: 16),
label: Text(isVoice ? 'Putar' : 'Bacakan',
style: const TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6), const EdgeInsets.symmetric(horizontal: 12, vertical: 6),

View File

@ -0,0 +1 @@
export '../../notification_screen.dart';

View File

@ -0,0 +1 @@
export '../../pairing_screens.dart';

View File

@ -27,7 +27,7 @@ class ServerConnectScreen extends StatefulWidget {
} }
class _ServerConnectScreenState extends State<ServerConnectScreen> { class _ServerConnectScreenState extends State<ServerConnectScreen> {
final _url = TextEditingController(text: 'http://202.46.28.160:8080'); final _url = TextEditingController();
bool _loading = false; bool _loading = false;
bool _ok = false; bool _ok = false;
String? _message; String? _message;
@ -76,7 +76,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Server URL', labelText: 'Server URL',
hintText: 'http://202.46.28.160:8080', hintText: 'http://server-ip:8080',
prefixIcon: Icon(Icons.dns_outlined), prefixIcon: Icon(Icons.dns_outlined),
)), )),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -0,0 +1 @@
export '../../user_settings_screen.dart';

View File

@ -0,0 +1,38 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../domain/repositories/sos_repository.dart';
enum SosPhase { idle, sending, sent, error }
class SosState {
final SosPhase phase;
final String? message;
const SosState({this.phase = SosPhase.idle, this.message});
}
class SosCubit extends Cubit<SosState> {
final SosRepository _repository;
SosCubit(this._repository) : super(const SosState());
Future<void> trigger({
String triggerType = 'MANUAL',
double? lat,
double? lng,
}) async {
emit(const SosState(phase: SosPhase.sending));
final result = await _repository.triggerSos(
triggerType: triggerType,
lat: lat,
lng: lng,
);
result.fold(
(failure) => emit(SosState(phase: SosPhase.error, message: failure.message)),
(_) => emit(const SosState(
phase: SosPhase.sent,
message: 'SOS terkirim. Guardian sudah diberi tahu.',
)),
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/network/api_client.dart';
import '../../domain/repositories/sos_repository.dart';
class SosRepositoryImpl implements SosRepository {
final ApiClient _apiClient;
const SosRepositoryImpl(this._apiClient);
@override
Future<Either<Failure, void>> triggerSos({
required String triggerType,
double? lat,
double? lng,
}) async {
try {
await _apiClient.dio.post('/user/sos', data: {
'triggerType': triggerType,
'lat': lat,
'lng': lng,
});
return const Right(null);
} catch (_) {
return const Left(NetworkFailure('SOS belum terkirim. Coba lagi.'));
}
}
}

View File

@ -0,0 +1,17 @@
class SosEvent {
final int? id;
final String triggerType;
final double? lat;
final double? lng;
final String status;
final DateTime? createdAt;
const SosEvent({
this.id,
required this.triggerType,
this.lat,
this.lng,
this.status = 'TRIGGERED',
this.createdAt,
});
}

View File

@ -0,0 +1,11 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
abstract class SosRepository {
Future<Either<Failure, void>> triggerSos({
required String triggerType,
double? lat,
double? lng,
});
}

View File

@ -0,0 +1 @@
export '../../sos_screen.dart';

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
@ -12,6 +13,7 @@ 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';
import 'application/sos_cubit.dart';
Dio get _api => sl<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -61,7 +63,7 @@ class SosScreen extends StatefulWidget {
class _SosScreenState extends State<SosScreen> class _SosScreenState extends State<SosScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
// State // State
bool _sending = false; late final SosCubit _sosCubit;
bool _historyLoading = true; bool _historyLoading = true;
List<_SosEvent> _events = const []; List<_SosEvent> _events = const [];
String? _historyError; String? _historyError;
@ -76,6 +78,7 @@ class _SosScreenState extends State<SosScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_sosCubit = sl<SosCubit>();
_pulseCtrl = AnimationController( _pulseCtrl = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
@ -89,6 +92,7 @@ class _SosScreenState extends State<SosScreen>
@override @override
void dispose() { void dispose() {
_pulseCtrl.dispose(); _pulseCtrl.dispose();
_sosCubit.close();
super.dispose(); super.dispose();
} }
@ -134,7 +138,7 @@ class _SosScreenState extends State<SosScreen>
} }
Future<void> _confirmAndSend() async { Future<void> _confirmAndSend() async {
if (_sending) return; if (_sosCubit.state.phase == SosPhase.sending) return;
// Confirmation dialog prevents accidental tap // Confirmation dialog prevents accidental tap
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
@ -178,15 +182,17 @@ class _SosScreenState extends State<SosScreen>
} }
Future<void> _sendSos() async { Future<void> _sendSos() async {
setState(() => _sending = true);
await runFriendlyAction( await runFriendlyAction(
() async { () async {
final pos = await _getPosition(); final pos = await _getPosition();
await _api.post('/user/sos', data: { await _sosCubit.trigger(
'triggerType': 'BUTTON', triggerType: 'BUTTON',
'lat': pos?.latitude, lat: pos?.latitude,
'lng': pos?.longitude, lng: pos?.longitude,
}); );
if (_sosCubit.state.phase == SosPhase.error) {
throw StateError(_sosCubit.state.message ?? 'Gagal kirim SOS.');
}
await sl<HapticService>().sosTriggered(); await sl<HapticService>().sosTriggered();
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.');
@ -195,19 +201,22 @@ class _SosScreenState extends State<SosScreen>
onError: _snack, onError: _snack,
fallback: 'Gagal kirim SOS. Coba lagi sebentar.', fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
); );
if (mounted) setState(() => _sending = false);
} }
// Build // Build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return BlocBuilder<SosCubit, SosState>(
child: Padding( bloc: _sosCubit,
padding: const EdgeInsets.all(16), builder: (context, sosState) {
child: Column( final sending = sosState.phase == SosPhase.sending;
crossAxisAlignment: CrossAxisAlignment.stretch, return SafeArea(
children: [ child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header // Header
Row( Row(
children: [ children: [
@ -247,7 +256,7 @@ class _SosScreenState extends State<SosScreen>
// SOS Button // SOS Button
Center( Center(
child: _sending child: sending
? const _SendingIndicator() ? const _SendingIndicator()
: AnimatedBuilder( : AnimatedBuilder(
animation: _pulseAnim, animation: _pulseAnim,
@ -288,15 +297,17 @@ class _SosScreenState extends State<SosScreen>
const SizedBox(height: 10), const SizedBox(height: 10),
Expanded( Expanded(
child: _SosHistory( child: _SosHistory(
loading: _historyLoading, loading: _historyLoading,
error: _historyError, error: _historyError,
events: _events, events: _events,
onRefresh: _loadHistory, onRefresh: _loadHistory,
)), )),
], ],
),
), ),
), );
},
); );
} }

View File

@ -0,0 +1,64 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/ai/obstacle_analyzer.dart';
import '../domain/repositories/walk_guide_repository.dart';
class WalkGuideState {
final bool active;
final DetectionResult? latestDetection;
final String status;
const WalkGuideState({
this.active = false,
this.latestDetection,
this.status = 'Ready',
});
WalkGuideState copyWith({
bool? active,
DetectionResult? latestDetection,
String? status,
}) {
return WalkGuideState(
active: active ?? this.active,
latestDetection: latestDetection ?? this.latestDetection,
status: status ?? this.status,
);
}
}
class WalkGuideCubit extends Cubit<WalkGuideState> {
final WalkGuideRepository _repository;
WalkGuideCubit(this._repository) : super(const WalkGuideState());
Future<void> start() async {
emit(state.copyWith(active: true, status: 'WalkGuide active'));
await _repository.startSession();
}
Future<void> stop() async {
emit(state.copyWith(active: false, status: 'WalkGuide stopped'));
await _repository.stopSession();
}
void updateStatus(String status) {
emit(state.copyWith(status: status));
}
void clearDetection({String status = 'Ready'}) {
emit(WalkGuideState(active: state.active, status: status));
}
Future<void> recordObstacle(DetectionResult detection) async {
emit(state.copyWith(latestDetection: detection, status: detection.spokenId));
await _repository.logObstacle({
'label': detection.label,
'confidence': detection.confidence,
'direction': detection.directionName,
'estimatedDist': detection.estimatedDistance,
'lat': null,
'lng': null,
});
}
}

View File

@ -0,0 +1,46 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/services/offline_queue_service.dart';
import '../../domain/repositories/walk_guide_repository.dart';
class WalkGuideRepositoryImpl implements WalkGuideRepository {
final ApiClient _apiClient;
final OfflineQueueService _offlineQueue;
const WalkGuideRepositoryImpl(this._apiClient, this._offlineQueue);
@override
Future<Either<Failure, void>> startSession() {
return _post('/user/walkguide/start', const {});
}
@override
Future<Either<Failure, void>> stopSession() {
return _post('/user/walkguide/stop', const {});
}
@override
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload) {
return _post('/user/obstacle', payload);
}
Future<Either<Failure, void>> _post(
String path,
Map<String, dynamic> payload,
) async {
try {
await _apiClient.dio.post(path, data: payload);
return const Right(null);
} catch (_) {
await _offlineQueue.enqueue(OfflineRequest(
method: 'POST',
path: path,
body: payload,
createdAt: DateTime.now(),
));
return const Right(null);
}
}
}

View File

@ -0,0 +1,13 @@
class WalkSession {
final String sessionId;
final DateTime startedAt;
final DateTime? stoppedAt;
final int obstacleCount;
const WalkSession({
required this.sessionId,
required this.startedAt,
this.stoppedAt,
this.obstacleCount = 0,
});
}

View File

@ -0,0 +1,9 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
abstract class WalkGuideRepository {
Future<Either<Failure, void>> startSession();
Future<Either<Failure, void>> stopSession();
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload);
}

View File

@ -0,0 +1 @@
export '../../walk_guide_screen.dart';

View File

@ -5,14 +5,16 @@ import 'dart:async';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/ai/detection_export.dart'; import '../../core/ai/detection_export.dart';
import '../../core/network/api_client.dart'; import '../../core/ai/obstacle_alert_strategy.dart';
import '../../core/services/haptic_service.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/services/location_reporter_service.dart'; import '../../core/services/location_reporter_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import 'application/walk_guide_cubit.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WalkGuideScreen // WalkGuideScreen
@ -26,15 +28,19 @@ class WalkGuideScreen extends StatefulWidget {
} }
class _WalkGuideScreenState extends State<WalkGuideScreen> { class _WalkGuideScreenState extends State<WalkGuideScreen> {
bool _active = false; late final WalkGuideCubit _cubit;
String _status = 'Ready';
CameraController? _camera; CameraController? _camera;
DetectionResult? _lastDetection;
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); DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
_cubit = sl<WalkGuideCubit>();
}
@override @override
void dispose() { void dispose() {
final camera = _camera; final camera = _camera;
@ -43,25 +49,23 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
} }
_camera?.dispose(); _camera?.dispose();
sl<LocationReporterService>().stop(); sl<LocationReporterService>().stop();
_cubit.close();
super.dispose(); super.dispose();
} }
Future<void> _toggle() async { Future<void> _toggle() async {
final next = !_active; final next = !_cubit.state.active;
if (next) { if (next) {
await _startCamera(); await _startCamera();
await sl<LocationReporterService>().start(walkGuideActive: true); await sl<LocationReporterService>().start(walkGuideActive: true);
await _cubit.start();
_cubit.updateStatus(_activeStatusText());
} else { } else {
await _stopCamera(); await _stopCamera();
await sl<LocationReporterService>().stop(); await sl<LocationReporterService>().stop();
await _cubit.stop();
_cubit.clearDetection(status: 'Stopped');
} }
setState(() {
_active = next;
_status = next ? _activeStatusText() : 'Stopped';
});
await sl<ApiClient>()
.dio
.post(next ? '/user/walkguide/start' : '/user/walkguide/stop');
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
} }
@ -80,7 +84,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
Future<void> _startCamera() async { Future<void> _startCamera() async {
if (_camera != null) return; if (_camera != null) return;
try { await runFriendlyAction(
() async {
final cameras = await availableCameras(); final cameras = await availableCameras();
if (cameras.isEmpty) return; if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere( final backCamera = cameras.firstWhere(
@ -98,33 +103,40 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
await controller.dispose(); await controller.dispose();
return; return;
} }
try { await runFriendlyAction(
await controller.startImageStream(_onCameraImage); () => controller.startImageStream(_onCameraImage),
} catch (_) { onError: (_) {
setState(() => _status = kIsWeb _cubit.updateStatus(kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.' ? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.'); : 'Camera preview aktif, tapi image stream belum tersedia.');
} },
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
);
setState(() => _camera = controller); setState(() => _camera = controller);
} catch (_) { },
setState(() => _status = 'Camera unavailable.'); onError: (_) => _cubit.updateStatus('Camera unavailable.'),
} fallback: 'Camera unavailable.',
);
} }
Future<void> _stopCamera() async { Future<void> _stopCamera() async {
final camera = _camera; final camera = _camera;
_camera = null; _camera = null;
if (camera == null) return; if (camera == null) return;
try { await runFriendlyAction(
if (camera.value.isStreamingImages) { () async {
await camera.stopImageStream(); if (camera.value.isStreamingImages) {
} await camera.stopImageStream();
} catch (_) {} }
},
onError: (_) {},
fallback: 'Camera stream already stopped.',
);
await camera.dispose(); await camera.dispose();
} }
void _onCameraImage(CameraImage image) { void _onCameraImage(CameraImage image) {
if (!_active || _processingFrame) return; if (!_cubit.state.active || _processingFrame) return;
final now = DateTime.now(); final now = DateTime.now();
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) { if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
return; return;
@ -141,7 +153,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
final now = DateTime.now(); final now = DateTime.now();
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) { if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
_lastModelWarningAt = now; _lastModelWarningAt = now;
setState(() => _status = detector.isReady _cubit.updateStatus(detector.isReady
? 'Scanning... ${detector.diagnosticsSummary}' ? 'Scanning... ${detector.diagnosticsSummary}'
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.'); : 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
} }
@ -154,8 +166,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
DetectionResult detection, { DetectionResult detection, {
bool forceAlert = false, bool forceAlert = false,
}) async { }) async {
_lastDetection = detection; _cubit.updateStatus(
setState(() => _status =
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}'); 'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
final now = DateTime.now(); final now = DateTime.now();
@ -165,94 +176,94 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
} }
_lastAlertAt = now; _lastAlertAt = now;
try { await runFriendlyAction(
await sl<ApiClient>().dio.post('/user/obstacle', data: { () => _cubit.recordObstacle(detection),
'label': detection.label, onError: (_) {},
'confidence': detection.confidence, fallback: 'Obstacle tersimpan offline.',
'direction': detection.directionName, );
'estimatedDist': detection.estimatedDistance, await sl<ObstacleAlertStrategy>().alert(detection);
'lat': null,
'lng': null,
});
} catch (_) {}
await sl<HapticService>().obstacleClose();
await sl<TtsService>().speakImmediate(detection.spokenId);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _Page( return BlocBuilder<WalkGuideCubit, WalkGuideState>(
title: 'WalkGuide', bloc: _cubit,
subtitle: 'On-device AI detection surface', builder: (context, state) => _Page(
actions: [ title: 'WalkGuide',
IconButton( subtitle: 'On-device AI detection surface',
onPressed: () => context.go('/user/benchmark'), actions: [
icon: const Icon(Icons.speed)), IconButton(
IconButton( onPressed: () => context.go('/user/benchmark'),
onPressed: () => context.go('/user/pairing'), icon: const Icon(Icons.speed)),
icon: const Icon(Icons.link)), IconButton(
], onPressed: () => context.go('/user/pairing'),
child: Column( icon: const Icon(Icons.link)),
children: [ ],
Expanded( child: Column(
child: Container( children: [
width: double.infinity, Expanded(
decoration: BoxDecoration( child: Container(
color: const Color(0xFF0F172A), width: double.infinity,
borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
child: Stack( color: const Color(0xFF0F172A),
children: [ borderRadius: BorderRadius.circular(16)),
if (_camera != null && _camera!.value.isInitialized) child: Stack(
Positioned.fill(child: CameraPreview(_camera!)) children: [
else if (_camera != null && _camera!.value.isInitialized)
const Center( Positioned.fill(child: CameraPreview(_camera!))
child: Icon(Icons.videocam_outlined, else
color: Colors.white30, size: 96)), const Center(
if (_lastDetection?.box != null) child: Icon(Icons.videocam_outlined,
Positioned.fill( color: Colors.white30, size: 96)),
child: CustomPaint( if (state.latestDetection?.box != null)
painter: _DetectionOverlayPainter(_lastDetection!), Positioned.fill(
child: CustomPaint(
painter:
_DetectionOverlayPainter(state.latestDetection!),
),
), ),
),
Positioned(
top: 16,
left: 16,
child: _Pill(
text: _active ? 'AI ACTIVE' : 'STANDBY',
color: _active ? Colors.green : Colors.orange)),
if (_lastDetection != null)
Positioned( Positioned(
top: 64, top: 16,
left: 16, left: 16,
child: _Pill( child: _Pill(
text: text: state.active ? 'AI ACTIVE' : 'STANDBY',
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}', color:
color: Colors.redAccent), state.active ? Colors.green : Colors.orange)),
), if (state.latestDetection != null)
Positioned( Positioned(
left: 16, top: 64,
right: 16, left: 16,
bottom: 16, child: _Pill(
child: Text(_status, text:
style: const TextStyle( '${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
color: Colors.white, color: Colors.redAccent),
fontSize: 18, ),
fontWeight: FontWeight.w700))), Positioned(
], left: 16,
right: 16,
bottom: 16,
child: Text(state.status,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700))),
],
),
), ),
), ),
), const SizedBox(height: 14),
const SizedBox(height: 14), Row(
Row( children: [
children: [ Expanded(
Expanded( child: FilledButton.icon(
child: FilledButton.icon( onPressed: _toggle,
onPressed: _toggle, icon:
icon: Icon(_active ? Icons.stop : Icons.play_arrow), Icon(state.active ? Icons.stop : Icons.play_arrow),
label: Text(_active ? 'Stop' : 'Start'))), label: Text(state.active ? 'Stop' : 'Start'))),
], ],
), ),
], ],
),
), ),
); );
} }

View File

@ -4,25 +4,21 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'app/injection_container.dart'; import 'app/injection_container.dart';
import 'app/app.dart'; import 'app/app.dart';
import 'core/utils/init_guard.dart';
List<CameraDescription> cameras = []; List<CameraDescription> cameras = [];
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Init cameras cameras = await ignoreInitFailure(
try { availableCameras,
cameras = await availableCameras(); label: 'Camera init',
} catch (e) { ) ??
debugPrint('Camera init error: $e'); [];
}
if (!kIsWeb) { if (!kIsWeb) {
try { await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init');
await Firebase.initializeApp();
} catch (e) {
debugPrint('Firebase init skipped: $e');
}
} }
// Init GetIt dependencies // Init GetIt dependencies

View File

@ -4,6 +4,9 @@ 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/network/api_client.dart';
import '../../core/services/hardware_shortcut_listener.dart';
import '../../core/services/stt_service.dart'; import '../../core/services/stt_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/services/voice_command_handler.dart'; import '../../core/services/voice_command_handler.dart';
@ -20,6 +23,8 @@ class _UserShellState extends State<UserShell> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadVoiceCommands();
_startHardwareShortcuts();
sl<SttService>().startListening(); sl<SttService>().startListening();
sl<VoiceCommandHandler>().onCommand = (key) { sl<VoiceCommandHandler>().onCommand = (key) {
if (!mounted) return; if (!mounted) return;
@ -58,6 +63,70 @@ class _UserShellState extends State<UserShell> {
}; };
} }
Future<void> _loadVoiceCommands() async {
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.get('/user/voice-commands')
.timeout(const Duration(seconds: 8));
final body = res.data;
final data = body is Map ? body['data'] : body;
if (data is! List) return;
final commands = data
.whereType<Map>()
.map((item) => _voiceCommandFromJson(Map<String, dynamic>.from(item)))
.whereType<VoiceCommand>()
.toList();
if (commands.isNotEmpty) {
sl<VoiceCommandHandler>().loadCommands(commands);
}
},
onError: (_) {},
fallback: 'Voice command belum bisa dimuat.',
);
}
Future<void> _startHardwareShortcuts() async {
await runFriendlyAction(
() => sl<HardwareShortcutListener>().startListening(
onAction: (action) {
if (!mounted) return;
switch (action) {
case HardwareShortcutAction.callGuardian:
context.go('/user/call');
sl<TtsService>().speak('Memanggil guardian');
break;
case HardwareShortcutAction.startWalkguide:
context.go('/user/walkguide');
sl<TtsService>().speak('WalkGuide dibuka');
break;
case HardwareShortcutAction.stopWalkguide:
context.go('/user/walkguide');
sl<TtsService>().speak('WalkGuide dibuka untuk dihentikan');
break;
case HardwareShortcutAction.sendSos:
context.go('/user/sos');
sl<TtsService>().speak('SOS dibuka');
break;
case HardwareShortcutAction.openNotification:
context.go('/user/notifications');
sl<TtsService>().speak('Notifikasi dibuka');
break;
}
},
),
onError: (_) {},
fallback: 'Hardware shortcut belum bisa dimuat.',
);
}
@override
void dispose() {
sl<HardwareShortcutListener>().stopListening();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
@ -169,3 +238,49 @@ String _spokenRouteName(VoiceCommandKey key) {
return ''; return '';
} }
} }
VoiceCommand? _voiceCommandFromJson(Map<String, dynamic> item) {
final key = _commandKeyFromBackend(item['commandKey']?.toString());
final phrase = item['triggerPhrase']?.toString().trim();
if (key == null || phrase == null || phrase.isEmpty) return null;
return VoiceCommand(
key: key,
phrase: phrase,
enabled: item['enabled'] != false,
);
}
VoiceCommandKey? _commandKeyFromBackend(String? key) {
switch (key) {
case 'OPEN_WALKGUIDE':
return VoiceCommandKey.openWalkguide;
case 'START_WALKGUIDE':
return VoiceCommandKey.startWalkguide;
case 'STOP_WALKGUIDE':
return VoiceCommandKey.stopWalkguide;
case 'CALL_GUARDIAN':
return VoiceCommandKey.callGuardian;
case 'OPEN_NOTIFICATION':
return VoiceCommandKey.openNotification;
case 'READ_ALL_NOTIF':
return VoiceCommandKey.readAllNotif;
case 'OPEN_SOS':
return VoiceCommandKey.openSos;
case 'SEND_SOS':
return VoiceCommandKey.sendSos;
case 'WHERE_AM_I':
return VoiceCommandKey.whereAmI;
case 'OPEN_ACTIVITY':
return VoiceCommandKey.openActivity;
case 'OPEN_NAVIGATION':
return VoiceCommandKey.openNavigation;
case 'OPEN_SETTINGS':
return VoiceCommandKey.openSettings;
case 'REPEAT_LAST':
return VoiceCommandKey.repeatLast;
case 'STOP_TTS':
return VoiceCommandKey.stopTts;
default:
return null;
}
}

View File

@ -947,10 +947,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.16.0"
mgrs_dart: mgrs_dart:
dependency: transitive dependency: transitive
description: description:
@ -1501,7 +1501,7 @@ packages:
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3: sqlite3:
dependency: transitive dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
@ -1592,26 +1592,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.3" version: "1.26.2"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.6"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.12" version: "0.6.11"
tflite_flutter: tflite_flutter:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -23,6 +23,7 @@ dependencies:
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
drift: ^2.18.0 drift: ^2.18.0
sqlite3: ^2.4.7
sqlite3_flutter_libs: ^0.5.24 sqlite3_flutter_libs: ^0.5.24
path_provider: ^2.1.3 path_provider: ^2.1.3
path: ^1.9.0 path: ^1.9.0

View File

@ -0,0 +1,123 @@
// Live Spring Boot E2E smoke tests.
//
// Run on a physical Android device/profile build when the backend is online:
// flutter test test/integration_test/live_api_e2e_test.dart \
// --dart-define=LIVE_API_BASE_URL=http://202.46.28.160:8080/api/v1 \
// --dart-define=LIVE_USER_EMAIL=user@example.com \
// --dart-define=LIVE_USER_PASSWORD=password \
// --dart-define=LIVE_GUARDIAN_EMAIL=guardian@example.com \
// --dart-define=LIVE_GUARDIAN_PASSWORD=password
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
const _baseUrl = String.fromEnvironment('LIVE_API_BASE_URL');
const _userEmail = String.fromEnvironment('LIVE_USER_EMAIL');
const _userPassword = String.fromEnvironment('LIVE_USER_PASSWORD');
const _guardianEmail = String.fromEnvironment('LIVE_GUARDIAN_EMAIL');
const _guardianPassword = String.fromEnvironment('LIVE_GUARDIAN_PASSWORD');
bool get _liveApiConfigured =>
_baseUrl.isNotEmpty &&
_userEmail.isNotEmpty &&
_userPassword.isNotEmpty &&
_guardianEmail.isNotEmpty &&
_guardianPassword.isNotEmpty;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('WalkGuide live Spring Boot API E2E', () {
late Dio dio;
String? userToken;
String? guardianToken;
setUpAll(() {
dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
validateStatus: (status) => status != null && status < 500,
));
});
testWidgets('flow 1: ping, user login, profile', (tester) async {
if (!_liveApiConfigured) {
// Keep CI green when live credentials are not injected.
// The final benchmark run should pass LIVE_* dart-defines.
return;
}
final ping = await dio.get('/auth/ping');
expect(ping.statusCode, 200);
final login = await dio.post('/auth/login', data: {
'email': _userEmail,
'password': _userPassword,
});
expect(login.statusCode, 200);
userToken = login.data['data']['accessToken'] as String?;
expect(userToken, isNotNull);
final profile = await dio.get(
'/user/profile',
options: Options(headers: {'Authorization': 'Bearer $userToken'}),
);
expect(profile.statusCode, 200);
expect(profile.data['data']['email'], isNotEmpty);
});
testWidgets('flow 2: WalkGuide start, SOS, stop', (tester) async {
if (!_liveApiConfigured) {
// Keep CI green when live credentials are not injected.
// The final benchmark run should pass LIVE_* dart-defines.
return;
}
expect(userToken, isNotNull);
final auth = Options(headers: {'Authorization': 'Bearer $userToken'});
final start = await dio.post('/user/walkguide/start', options: auth);
expect(start.statusCode, 200);
final sos = await dio.post('/user/sos', data: {
'triggerType': 'MANUAL',
'lat': null,
'lng': null,
}, options: auth);
expect(sos.statusCode, 200);
final stop = await dio.post('/user/walkguide/stop', options: auth);
expect(stop.statusCode, 200);
});
testWidgets('flow 3: guardian dashboard and user notifications', (tester) async {
if (!_liveApiConfigured) {
// Keep CI green when live credentials are not injected.
// The final benchmark run should pass LIVE_* dart-defines.
return;
}
final guardianLogin = await dio.post('/auth/login', data: {
'email': _guardianEmail,
'password': _guardianPassword,
});
expect(guardianLogin.statusCode, 200);
guardianToken = guardianLogin.data['data']['accessToken'] as String?;
expect(guardianToken, isNotNull);
final guardianAuth =
Options(headers: {'Authorization': 'Bearer $guardianToken'});
final dashboard = await dio.get('/guardian/dashboard', options: guardianAuth);
expect(dashboard.statusCode, 200);
final userAuth = Options(headers: {'Authorization': 'Bearer $userToken'});
final notifications = await dio.get('/user/notifications', options: userAuth);
expect(notifications.statusCode, 200);
final markAll =
await dio.put('/user/notifications/mark-all-read', options: userAuth);
expect(markAll.statusCode, 200);
});
});
}