Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli

expo-env.d.ts
# @end expo-cli

.env
node_modules/
.expo/
1 change: 1 addition & 0 deletions mobile/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
167 changes: 167 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# GateGuard Mobile

역무원용 무임승차 알림 앱 — Expo (React Native) 기반.
백엔드에서 감지된 이벤트를 WebSocket으로 실시간 수신하고, 포그라운드/백그라운드 상황에 맞게 알림을 전달합니다.

---

## 빠른 시작

### 1. 의존성 설치

```bash
cd mobile
npm install
```

### 2. `.env` 파일 생성

```bash
# mobile/.env
EXPO_PUBLIC_API_URL=http://<서버 IP>:8000
```

서버 IP 확인 (서버 컴퓨터에서 실행):

```powershell
# Windows
ipconfig
# 무선 LAN 어댑터 Wi-Fi → IPv4 주소 확인 (예: 172.30.1.78)

# Mac / Linux
ifconfig | grep inet
```

> **주의:** `mobile/.env`는 깃에 올리지 마세요. IP는 환경마다 다릅니다.
> Android 에뮬레이터는 `10.0.2.2`, iOS 시뮬레이터는 `localhost` 사용 가능합니다.

### 3. 백엔드 먼저 실행

```bash
# 프로젝트 루트에서
docker-compose up -d
```

### 4. Expo 실행

```bash
npx expo start --clear
```

Expo Go 앱으로 QR 코드를 스캔하세요.

### 로그인 계정

| 사원번호 | 비밀번호 | 역할 |
|---|---|---|
| 2026001 | admin1234 | 관리자 |
| 2026002 | station1234 | 역무원 |

---

## 화면 구성

| 경로 | 설명 |
|------|------|
| `/(auth)/login` | 사원번호 + 비밀번호 로그인 |
| `/(main)/` | 대시보드 — 통계 카드 / 최신 알림 목록 |
| `/(main)/settings` | 설정 — 로그아웃 / 개발자 테스트(개발 빌드 전용) |

---

## 주요 기능

### 실시간 이벤트 수신
WebSocket(`/ws/events`)으로 `NEW_EVENT` 메시지를 수신합니다.

- **앱 포그라운드** → 화면 중앙에 인앱 팝업(`EventAlertPopup`) 표시, 5초 후 자동 닫힘
- **앱 백그라운드** → 시스템 로컬 알림 발송, 탭하면 해당 이벤트 상세 화면으로 딥링크

### 딥링크
시스템 알림을 탭하면 앱이 켜지면서 해당 이벤트의 상세 모달이 자동으로 열립니다.
구현 위치: `app/_layout.tsx` → `app/(main)/index.tsx`

### 이벤트 처리
- **처리완료** — `PATCH /api/events/{id}/status`
- **오탐신고** — `POST /api/events/{id}/false-alarm` (사유 선택 + 직접 입력)

### 인증
JWT를 `AsyncStorage`에 저장하고, 앱 시작 시 자동 복원합니다.
401 응답 시 토큰을 삭제하고 로그인 화면으로 이동합니다.

---

## 디렉토리 구조

```
mobile/
├── app/
│ ├── _layout.tsx # 루트 레이아웃 — 폰트, 알림 권한, 딥링크 리스너
│ ├── index.tsx # 진입점 — 인증 여부에 따라 메인/로그인 분기
│ ├── (auth)/
│ │ └── login.tsx # 로그인 화면
│ └── (main)/
│ ├── _layout.tsx # 탭 네비게이터 (대시보드 / 설정)
│ ├── index.tsx # 대시보드 메인 화면
│ └── settings.tsx # 설정 화면
├── components/
│ ├── events/
│ │ ├── AlertRow.tsx # 알림 목록 한 줄 컴포넌트
│ │ ├── EventDetailModal.tsx # 이벤트 상세 바텀 시트
│ │ └── FalseAlarmModal.tsx # 오탐신고 바텀 시트
│ └── ui/
│ ├── AppLoadingScreen.tsx # 폰트 로드 전 스플래시 화면
│ ├── Badge.tsx # 상태/위험도 배지
│ ├── EventAlertPopup.tsx # 포그라운드 인앱 팝업
│ ├── SectionHeader.tsx # 섹션 제목 + 배지
│ └── StatCard.tsx # 통계 카드 (2열 그리드)
├── hooks/
│ ├── useDashboard.ts # 3개 API 병렬 호출 + 카메라 조인 + 낙관적 업데이트
│ ├── useNotifications.ts # 로컬 알림 권한 요청 / 발송
│ └── useWebSocket.ts # WebSocket 연결 + 자동 재연결 (3초)
├── services/
│ ├── api.ts # Axios 인스턴스 — JWT 인터셉터
│ ├── authService.ts # 로그인 / 로그아웃 / 토큰 복원
│ ├── cameraService.ts # 카메라 목록 조회
│ └── eventService.ts # 이벤트 CRUD + 통계
├── store/
│ └── AuthContext.tsx # 인증 전역 상태 (Context + Provider)
├── constants/
│ ├── colors.ts # 색상 팔레트 + 그림자 프리셋
│ └── config.ts # API URL / WS URL / 토큰 키
├── types/
│ └── index.ts # 공유 타입 (웹 프론트와 동일)
└── utils/
├── eventHelpers.ts # 상태 색상/라벨, 위험도 배지, 위치 텍스트
└── format.ts # 시각 포맷 함수
```

---

## API 연동 요약

| 서비스 | 엔드포인트 |
|--------|-----------|
| 로그인 | `POST /api/auth/login` |
| 카메라 목록 | `GET /api/cameras/` |
| 이벤트 목록 | `GET /api/events/` |
| 이벤트 단건 | `GET /api/events/{id}` |
| 이벤트 통계 | `GET /api/events/stats` |
| 처리완료 | `PATCH /api/events/{id}/status` |
| 오탐신고 | `POST /api/events/{id}/false-alarm` |
| WebSocket | `WS /ws/events` |

---

## 참고

- Expo Go SDK 53+에서는 원격 푸시(`getExpoPushTokenAsync`)가 제거됐습니다. 현재는 로컬 알림만 사용하며 Expo Go에서 정상 동작합니다. 원격 푸시가 필요하면 EAS 개발 빌드로 전환하세요.
- `__DEV__` 플래그로 감싼 개발자 테스트 UI는 `npx expo start`(개발 빌드)에서만 보이고, EAS 프로덕션 빌드에서는 자동으로 제거됩니다.
- 웹 프론트(`frontend/`)와 타입 정의(`types/index.ts`)를 공유합니다. 타입 변경 시 양쪽을 함께 수정하세요.
46 changes: 46 additions & 0 deletions mobile/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"expo": {
"name": "GateGuard",
"slug": "gateguard-mobile",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"icon": "./assets/logo.png",
"splash": {
"image": "./assets/logo.png",
"resizeMode": "contain",
"backgroundColor": "#1a56db"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.gateguard.mobile"
},
"android": {
"package": "com.gateguard.mobile",
"adaptiveIcon": {
"foregroundImage": "./assets/logo.png",
"backgroundColor": "#1a56db"
}
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"color": "#1a56db"
}
],
"expo-font"
],
"scheme": "gateguard",
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "9125ff1d-0731-4afc-8a94-77d7cdfc1bdb"
}
}
}
}
141 changes: 141 additions & 0 deletions mobile/app/(auth)/find-pw.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 비밀번호 찾기 화면 — 사원번호 + 이메일 대조, 안내 메시지 표시
import React, { useState, useRef } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
StyleSheet, KeyboardAvoidingView, Platform,
ScrollView, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { Colors, Shadows } from '@/constants/colors';
import { findPassword } from '@/services/authService';

const BG = '#4880FF';

export default function FindPasswordScreen() {
const [employeeId, setEmployeeId] = useState('');
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');

const emailRef = useRef<TextInput>(null);

const handleSubmit = async () => {
if (!employeeId.trim() || !email.trim()) {
setError('사원번호와 이메일을 모두 입력해주세요.');
return;
}

setError('');
setSuccess('');
setLoading(true);
try {
const res = await findPassword(employeeId.trim(), email.trim());
setSuccess(res.message);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
setError(msg ?? '일치하는 사원 정보가 없습니다.');
} finally {
setLoading(false);
}
};

return (
<View style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* 뒤로가기 */}
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Text style={styles.backText}>← 로그인으로</Text>
</TouchableOpacity>

<View style={styles.card}>
<Text style={styles.cardTitle}>비밀번호 찾기</Text>
<Text style={styles.cardDesc}>
가입 시 등록한 사원번호와 이메일을 입력하면{'\n'}비밀번호 재설정 안내를 보내드립니다.
</Text>

<View style={styles.field}>
<Text style={styles.label}>사원번호</Text>
<TextInput
style={styles.input}
placeholder="2456123"
placeholderTextColor={Colors.gray400}
value={employeeId}
onChangeText={setEmployeeId}
autoCapitalize="none"
keyboardType="number-pad"
returnKeyType="next"
onSubmitEditing={() => emailRef.current?.focus()}
/>
</View>

<View style={styles.field}>
<Text style={styles.label}>이메일</Text>
<TextInput
ref={emailRef}
style={styles.input}
placeholder="example@korail.com"
placeholderTextColor={Colors.gray400}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</View>

{!!error && <Text style={styles.errorText}>{error}</Text>}
{!!success && <Text style={styles.successText}>{success}</Text>}

<TouchableOpacity
style={[styles.btn, loading && { opacity: 0.6 }]}
onPress={handleSubmit}
disabled={loading}
activeOpacity={0.85}
>
{loading
? <ActivityIndicator color={Colors.white} />
: <Text style={styles.btnText}>확인</Text>}
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: BG },
safeArea: { flex: 1 },
scrollContent: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 40 },

backBtn: { marginBottom: 16 },
backText: { fontSize: 14, fontFamily: 'Inter_600SemiBold', color: Colors.white, opacity: 0.85 },

card: { backgroundColor: Colors.white, borderRadius: 22, padding: 28, ...Shadows.lg },
cardTitle: { fontSize: 22, fontFamily: 'Inter_700Bold', color: Colors.gray900, marginBottom: 8, textAlign: 'center', letterSpacing: 0.2 },
cardDesc: { fontSize: 13, fontFamily: 'Inter_400Regular', color: Colors.gray500, textAlign: 'center', lineHeight: 20, marginBottom: 24 },

field: { marginBottom: 14 },
label: { fontSize: 13, fontFamily: 'Inter_600SemiBold', color: Colors.gray700, marginBottom: 6 },
input: {
borderWidth: 1, borderColor: Colors.gray200, borderRadius: 12,
paddingHorizontal: 14, paddingVertical: 13,
fontSize: 15, fontFamily: 'Inter_400Regular', color: Colors.gray900, backgroundColor: Colors.gray50,
},

errorText: { fontSize: 13, fontFamily: 'Inter_400Regular', color: Colors.danger, marginBottom: 12, textAlign: 'center' },
successText: { fontSize: 13, fontFamily: 'Inter_400Regular', color: Colors.success, marginBottom: 12, textAlign: 'center', lineHeight: 20 },

btn: { backgroundColor: BG, borderRadius: 50, paddingVertical: 15, alignItems: 'center', marginTop: 4, ...Shadows.md },
btnText: { color: Colors.white, fontSize: 16, fontFamily: 'Inter_600SemiBold', letterSpacing: 0.5 },
});
Loading
Loading