
두 개의 아이콘 상태를 애니메이션으로 전환해주는 Flutter 위젯
대표 예:
☰ ↔ ✕ (메뉴 열기/닫기)
▶ ↔ ⏸ (재생/일시정지)
🔍 ↔ ← (검색/뒤로)
AnimatedIcon(
icon: AnimatedIcons.menu_close,
progress: animationController,
)
| 속성 | 설명 |
|---|---|
icon | 미리 정의된 AnimatedIcons |
progress | Animation<double> (보통 AnimationController) |
Flutter에서 미리 제공하는 아이콘 세트
| 아이콘 | 설명 |
|---|---|
menu_close | 메뉴 ↔ 닫기 |
play_pause | 재생 ↔ 일시정지 |
search_arrow | 검색 ↔ 뒤로 |
arrow_menu | 화살표 ↔ 메뉴 |
ellipsis_search | 더보기 ↔ 검색 |
커스텀 아이콘 불가
→ Flutter에서 정의한 것만 사용 가능
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
AnimatedIcon(
icon: AnimatedIcons.menu_close,
progress: _controller,
size: 32,
color: Colors.black,
)
▶ 열기 / 닫기 토글
void toggle() {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
}
IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.menu_close,
progress: _controller,
),
onPressed: toggle,
)
void dispose() {
_controller.dispose();
super.dispose();
}
📍 안 하면 메모리 누수 발생
AspectRatio는 자식 위젯의 가로:세로 비율을 고정해주는 위젯
부모 위젯의 제약 안에서 지정한 비율을 최대한 유지하며 크기를 결정
AspectRatio(
aspectRatio: 16 / 9,
child: Widget,
)
aspectRatio: 가로 ÷ 세로
16 / 9 → 가로가 더 긴 형태
1 / 1 → 정사각형
3 / 4 → 세로가 더 긴 형태
부모의 크기 제약을 먼저 받음
그 안에서 aspectRatio를 만족하는 최대 크기를 계산
자식 위젯은 그 크기에 맞춰 배치됨
❗ 부모 제약이 없으면 의미 없음
Column(
children: [
AspectRatio( // ❌ 높이 제약 없음
aspectRatio: 16 / 9,
child: Container(),
),
],
)
이런 경우 Expanded, SizedBox, Container 등으로 부모 크기 제한 필요
LimitedBox는 부모로부터 크기 제한을 받지 못할 때만
자식 위젯의 최대 크기를 제한하는 위젯이다.
“제한이 없을 때만, 내가 제한을 걸겠다”는 개념
Flutter 레이아웃에서
ListView, SingleChildScrollView 같은 위젯은
스크롤 방향으로 무한한 크기(unbounded constraints) 를 전달함
이때 자식 위젯이 얼마나 커져야 할지 모르는 문제가 발생할 수 있음
LimitedBox(
maxHeight: 200,
child: Container(
color: Colors.blue,
),
)
의미
부모가 높이를 제한해주지 않을 경우에만
child의 높이를 최대 200으로 제한
| 부모의 제약 상태 | LimitedBox 동작 |
|---|---|
| 제약 있음 (bounded) | ❌ 아무것도 안 함 |
| 제약 없음 (unbounded) | ✅ maxWidth / maxHeight 적용 |
ListView(
children: [
LimitedBox(
maxHeight: 150,
child: Container(color: Colors.red),
),
],
)
ListView는 세로 방향 height가 무한
LimitedBox가 최대 높이 제한 역할
SingleChildScrollView(
child: LimitedBox(
maxWidth: 300,
child: Container(color: Colors.green),
),
)
국토교통부에서 운영하는 공간정보 API
위도·경도 → 행정주소(읍·면·동)
검색어 → 행정구역 주소 목록
www.vworld.kr
회원가입 & 로그인
오픈API → 인증키 발급
발급된 API Key 복사해서 보관
https://api.vworld.kr/req/data?request=GetFeature&data=LT_C_ADEMD_INFO&key=발급받은_키&geomfilter=point(경도 위도)&geometry=false&size=100
적용 예시
https://api.vworld.kr/req/data?request=GetFeature&data=LT_C_ADEMD_INFO&key=6D3D0CF6-EDAC-3C0B-BDC6-4FCDF597CE1E&geomfilter=point(129.0823133 35.2202216)&geometry=false&size=100
필수 파라미터
결과: full_nm (전체 행정 주소)
{
"response": {
"service": {
"name": "data",
"version": "2.0",
"operation": "GetFeature",
"time": "20(ms)"
},
"status": "OK",
"record": {
"total": "1",
"current": "1"
},
"page": {
"total": "1",
"current": "1",
"size": "100"
},
"result": {
"featureCollection": {
"type": "FeatureCollection",
"bbox": [0, 0, -1, -1],
"features": [
{
"type": "Feature",
"properties": {
"emd_eng_nm": "Oncheon-dong",
"emd_kor_nm": "온천동",
"full_nm": "부산광역시 동래구 온천동",
"emd_cd": "26260108"
},
"id": "LT_C_ADEMD_INFO.309111"
}
]
}
}
}
}
https://api.vworld.kr/req/search?request=search&key=&발급받은 키query=검색어&type=DISTRICT&category=L4&size=100
적용예시
https://api.vworld.kr/req/search?request=search&key=6D3D0CF6-EDAC-3C0B-BDC6-4FCDF597CE1E&query=%EC%98%A8%EC%B2%9C&type=DISTRICT&category=L4&size=100
필수 파라미터
결과: 여러 지역의 행정주소 목록
{
"response": {
"service": {
"name": "search",
"version": "2.0",
"operation": "search",
"time": "16(ms)"
},
"status": "OK",
"record": {
"total": "2",
"current": "2"
},
"page": {
"total": "1",
"current": "1",
"size": "100"
},
"result": {
"crs": "EPSG:4326",
"type": "DISTRICT",
"items": [
{
"id": "26260108",
"title": "부산광역시 동래구 온천동",
"geometry": "http://map.vworld.kr/data/geojson/district/26260108.geojson",
"point": {
"x": "129.066277388",
"y": "35.2048777219"
}
},
{
"id": "44200101",
"title": "충청남도 아산시 온천동",
"geometry": "http://map.vworld.kr/data/geojson/district/44200101.geojson",
"point": {
"x": "126.999517569",
"y": "36.7871179847"
}
}
]
}
}
}
Flutter에서 HTTP 통신을 쉽게 해주는 패키지
JSON 자동 파싱
인터셉터로 공통 헤더 관리 가능
flutter pub add dio
API 호출 로직을 UI와 분리
테스트 가능
유지보수 쉬움
class VWorldRepository {
final Dio _client = Dio(
BaseOptions(
// 설정안할 시 실패 응답 시 throw 던져서 에러남
validateStatus: (status) => true,
),
);
/req/search 사용
결과에서 title만 추출
// 1. 이름으로 검색하는 기능
Future<List<String>> findByName(String query) async {
final response = await _client.get(
'https://api.vworld.kr/req/search',
queryParameters: {
'request': 'search',
'key': '***',
'query': query,
'type': 'DISTRICT',
'category': 'L4',
'size': 100, // Optional
},
);
if (response.statusCode == 200 &&
response.data['response']['status'] == 'OK') {
// 행정주소 외 정보는 쓰지 않아서 모델생성 X(개인취향)
// 써드파티 API(외부 API) 모델링 시 프로젝트에 외부 모델이 추가가되어 관리 힘듦
return List.of(
response.data['response']['result']['items'],
).map((e) => e['title'].toString()).toList();
}
return [];
}
/req/data 사용
geomfilter: point(lng lat)
properties.full_nm 추출
// 2. 위도 경도로 검색하는 기능
Future<List<String>> findByLatLng({
required double lat,
required double lng,
}) async {
final response = await _client.get(
'https://api.vworld.kr/req/data',
queryParameters: {
'request': 'GetFeature',
'data': 'LT_C_ADEMD_INFO',
'key': '6CC563BB-FB85-3CF4-9DEE-C2AACA9BFD96',
'geomfilter': 'point($lng $lat)',
'geometry': 'false',
'size': 100, // Optional
},
);
if (response.statusCode == 200 &&
response.data['response']['status'] == 'OK') {
// 행정주소 외 정보는 쓰지 않아서 모델생성 X(개인취향)
// 써드파티 API(외부 API) 모델링 시 프로젝트에 외부 모델이 추가가되어 관리 힘듦
return List.of(
response.data['response']['result']['featureCollection']['features'],
).map((e) => e['properties']['full_nm'].toString()).toList();
}
return [];
}
정상 케이스
검색어가 있을 때 결과 개수 체크
위경도 → 정확한 주소 반환
실패 케이스
없는 검색어 → 빈 리스트
잘못된 좌표 → 빈 리스트
📌 API 연동은 무조건 테스트 코드로 검증하는 게 좋음
기기 GPS 센서로 현재 위치(위도·경도) 획득
획득한 좌표를 VWORLD API에 전달
행정 주소(읍·면·동)로 변환 후 화면에 표시
Flutter에서 네이티브 GPS 센서를 쉽게 사용하게 해주는 패키지
위치 권한 요청 + 현재 위치 조회 기능 제공
flutter pub add geolocator
1. compileSdkVersion 수정
android/app/build.gradle
compileSdkVersion 36
2. 위치 권한 추가
android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
ios/Runner/Info.plist
<key>NSLocationWhenInUseUsageDescription</key>
<string>동네 정보를 가져오기 위해 위치 권한을 허용해 주세요</string>
권한 설명 문구 필수 (없으면 앱 실행 중 크래시)
외부 패키지는 Helper / Util 클래스로 분리해서 관리
class GeolocatorHelper {
static Future<Position?> getPosition() async {
final permission = await Geolocator.checkPermission();
// 1. 현재 권한이 허용되지 않았을 때 권한 요청하기
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
// 2. 권한 요청 후 결과가 거부일 때 리턴하기
final permission2 = await Geolocator.requestPermission();
if (permission2 == LocationPermission.denied ||
permission2 == LocationPermission.deniedForever) {
return null;
}
}
// 3. GeoLocator로 위치 가져와서 리턴
final position = Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 100,
),
);
return position;
}
}
권한 거부 시 null 반환 → UI에서 분기 처리 가능
GPS 정확도: LocationAccuracy.high
상태 타입: List<String>
행정주소 문자열만 사용 → 모델 생성 안 함
void searchByName(String query) async {
final result = await vworldRepository.findByName(query);
state = result;
}
void searchByLocation(double lat, double lng) async {ed
final result = await vworldRepository.findByLatLng(lat: lat, lng: lng);
state = result;
}
final addressSearchViewModel =
NotifierProvider.autoDispose<AdressSearchViewModel, List<String>>(() {
return AdressSearchViewModel();
});
autoDispose
페이지 벗어나면 상태 자동 제거
검색 화면에 적합
TextField.onSubmitted
Consumer(
builder: (context, ref, child) {
return TextField(
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
final viewModel = ref.read(addressSearchViewModel.notifier);
viewModel.searchByName(value);
}
},
decoration: InputDecoration(
hintText: '동명(읍,면)으로 검색 (ex. 서초동)',
// 이 텍스트 필드 contentPadding으로 사이즈 조정
contentPadding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
),
);
},
),
공백 제거 후 ViewModel search() 호출
child: Consumer(
builder: (context, ref, child) {
return ElevatedButton(
onPressed: () async {
// 1. GeoLocationHelper에서 위치 받아오기
final position = await GeolocatorHelper.getPosition();
if (position != null) {
final viewModel = ref.read(
addressSearchViewModel.notifier,
);
viewModel.searchByLocation(
position.latitude,
position.longitude,
);
}
},
child: Text('현재 위치로 찾기'),
);
},
),
버튼 클릭
GeolocatorHelper.getPosition()
좌표 있으면
searchByAddress(lat, lng) 호출
Consumer(
builder: (context, ref, child) {
final adresses = ref.watch(addressSearchViewModel);
return Expanded(
child: ListView.builder(
padding: EdgeInsets.symmetric(vertical: 10),
itemCount: adresses.length,
itemBuilder: (context, index) {
final item = adresses[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return JoinPage(asress: item);
},
),
);
},
child: Container(
height: 50,
width: double.infinity,
// 터치영역 설정을 위해 색상 지정
color: Colors.transparent,
child: Text(item, style: TextStyle(fontSize: 16)),
),
);
},
),
);
},
),
ref.watch(addressSearchViewModel)
ListView.builder로 주소 목록 출력
클릭 시 JoinPage로 주소 전달
REST = Representational State Transfer
서버의 DB 자원을 URL(URI)로 구분하고, HTTP 메서드로 CRUD를 수행하는 API
백엔드 서버와 연동하여 데이터 관리하는 구조
[Method] [URI] [HTTP Version]
[Headers]
[Body]
브라우저에서 http://www.111coding.com/post 접속 시:
GET /post HTTP/1.1
Host: www.111coding.com
| HTTP 메서드 | 용도 | 설명 |
|---|---|---|
| GET | 조회 | 서버에서 데이터를 가져올 때 |
| POST | 생성 | 새로운 데이터를 만들 때 |
| PUT | 전체 수정 | 기존 데이터를 전체 수정할 때 |
| PATCH | 부분 수정 | 데이터의 일부만 수정할 때 |
| DELETE | 삭제 | 데이터를 삭제할 때 |
접근할 데이터(리소스)를 URL로 구분
예시:
회원 정보 → /user
게시글 정보 → /post
요청 추가 정보 전달
User-Agent : 브라우저 정보
Authorization : 로그인 토큰
요청 시 서버로 전송할 데이터
주로 POST, PUT, PATCH에서 사용
[HTTP Version] [Status Code] [Status Text]
[Headers]
[Body]
| 코드 | 의미 | 상황 예시 |
|---|---|---|
| 200 | OK | 요청 성공 |
| 201 | Created | 새 리소스 생성 성공 |
| 400 | Bad Request | 잘못된 요청 데이터 |
| 401 | Unauthorized | 인증 필요, 로그인 안됨 |
| 403 | Forbidden | 권한 없음, 접근 금지 |
| 404 | Not Found | 리소스 없음 |
| 405 | Method Not Allowed | 잘못된 HTTP 메서드 |
| 409 | Conflict | 요청 충돌 (ex. 중복 ID) |
| 500 | Internal Server Error | 서버 에러 |
HTTP/1.1 200 OK
Location: http://www.111coding.com/post
[
{
"title": "hi",
"content": "Hello World!"
}
]
컨테이너 기반 가상화 도구
애플리케이션 실행에 필요한 모든 환경을 이미지(Image) 로 패키징
애플리케이션 실행에 필요한
설정 + 라이브러리 + 파일을 모두 포함한 패키지
Docker 이미지를 실행한 결과물
컨테이너 위에서 애플리케이션이 동작
https://www.docker.com/products/docker-desktop/
Download Docker Desktop 클릭
OS에 맞는 설치 파일 다운로드 후 설치
docker pull 111coding/market-server
실습용 REST API 서버 이미지 다운로드
docker run -p 8080:8080 \
-e HOST_IP=$(ifconfig en0 | grep 'inet ' | awk '{print $2}') \
-d 111coding/market-server
설명
-p 8080:8080
→ 로컬 포트와 컨테이너 포트 연결
-d
→ 백그라운드 실행
실행 후 Docker Desktop에서 컨테이너 실행 상태 확인 가능
브라우저에서 접속:
http://localhost:8080/docs
정상 동작 시
API 문서 화면(Swagger)이 표시됨
REST API 서버가 정상적으로 실행 중인 상태
REST API 문서화 도구
백엔드에서 API를 만들면
→ Swagger로 요청/응답 구조를 자동 문서화
브라우저에서 바로 API 요청 테스트 가능
Swagger에서는 API마다 요청 방식이 표시됨
각 API 옆에 색상과 함께 메서드가 표시됨
API 하나를 클릭하면 아래 정보 확인 가능:
확인 포인트:
실제 앱에서 받을 데이터 구조와 동일
로그인 흐름
로그인 API 실행
아이디 / 비밀번호 입력
정상 로그인 시
Access Token 발급
이후 API 요청 시
해당 토큰으로 인증 필요
Swagger에서는 Try it out 버튼으로 직접 요청 가능
입력 형식:
Bearer 발급받은_토큰
Bearer + 공백 + 토큰 값 필수
인증 자동화를 위한 세팅 & 로그인 기능 구현
목표
로그인 후 발급받은 토큰을
👉 모든 API 요청에 자동으로 포함
중복 코드 제거를 위해 Dio + Interceptor 사용
왜 BaseRemoteRepository를 만드나?
abstract class BaseRemoteRepository {
// BaseRemoteRepository.Client;
Dio get client => _client;
static final Dio _client = Dio(
BaseOptions(
baseUrl: 'http://{ip}:8080',
// 설정안할 시 실패 응답 시 throw 던져서 에러남
validateStatus: (status) => true,
),
)..interceptors.add(interceptor);
static AuthInterceptor interceptor = AuthInterceptor();
}
validateStatus: true요청 시 → Authorization 헤더 자동 추가
로그인 응답 시 → 토큰 자동 저장
요청 가로채기 (onRequest)
if (bearerToken != null) {
options.headers.addAll({'Authorization': bearerToken});
}
로그인 이후부터 모든 API 요청에
Authorization: Bearer xxx 자동 포함
응답 가로채기 (onResponse)
if (response.realUri.path == '/login' && response.statusCode == 200) {
final token = response.headers['Authorization'];
bearerToken = token?.first;
print('로그인 성공함:$bearerToken');
}
/login 성공 시
서버에서 내려준 Authorization 헤더
interceptor 내부에 저장 이후 요청들은 자동 인증
로그인 요청을 담당하는 Repository
class UserRepository extends BaseRemoteRepository {
Future<bool> login({
required String username,
required String password,
}) async {
final response = await client.post(
'/login',
data: {'username': username, 'password': password},
);
return response.statusCode == 200;
}
}
로그인 성공 여부만 bool로 반환
토큰 저장은 Interceptor가 담당
user_repository_test.dart
test('UserRepository : login test', () async {
final case1 =
await userRepository.login(username: 'tester', password: '1112');
expect(case1, false);
final case2 =
await userRepository.login(username: 'tester', password: '1111');
expect(case2, true);
});
❌ 잘못된 비밀번호 → false
✅ 올바른 계정 → true
// 1. 상태 클래스 만들기 x
// 2. 뷰 모델 만들기
class LoginViewModel {
final userRepository = UserRepository();
Future<bool> login({
required String username,
required String password,
}) async {
return await userRepository.login(username: username, password: password);
}
}
// 3. 뷰모델 관리자 만들기
final loginViewModel = Provider.autoDispose((ref) => LoginViewModel());
final result = await ref.read(loginViewModel).login(
username: idController.text,
password: pwController.text,
);
if (result && mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) {
return HomePage();
},
),
// 기존 Navigator Stack에 쌓여있는 MaterialPageRoute들을 인자로 넘겨서 차례대로 호출해줌.
// 이때 페이지를 Stack에 남길지 여부를 리턴
// true => 페이지쌓여있음
// false => 페이지 Pop
// 현재 스택에는 WelcomePage -> LoginPage로 쌓여있기 때문에
// LoginPage Stack에 남길지 여부, WelcomePage 남길지 두번 호출함
// 둘 다 pop할거기때문에 false
(route) {
// print(route);
return false;
},
);
List;
}
pushAndRemoveUntil
else if (!result && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('아이디와 비밀번호를 확인해 주세요'),
showCloseIcon: true,
behavior: SnackBarBehavior.floating,
),
);
}
의미: 위젯이 아직 화면(트리)에 붙어 있는지 여부
true → 살아 있음
false → dispose됨
왜 쓰나: 비동기 작업 후 setState/Navigator 호출 시 에러 방지
언제 쓰나: async/await 이후
클라이언트 → 서버로 파일(binary) 전송
요청 타입: multipart/form-data
서버 응답: 업로드된 파일 정보(JSON)
📄 lib/data/model/file_model.dart
class FileModel {
final int id;
final String url;
final String originName;
final String contentType;
final DateTime createdAt;
const FileModel({
required this.id,
required this.url,
required this.originName,
required this.contentType,
required this.createdAt,
});
// JSON → 객체
FileModel.fromJson(Map<String, dynamic> json)
: this(
id: json['id'],
url: json['url'],
originName: json['originName'],
contentType: json['contentType'],
createdAt: DateTime.parse(json['createdAt']),
);
// 객체 → JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'url': url,
'originName': originName,
'contentType': contentType,
'createdAt': createdAt.toIso8601String(),
};
}
}
서버 응답을 그대로 매핑하는 Response Model
DateTime.parse() / toIso8601String() 사용
파일 업로드 후 결과를 앱에서 활용 가능
📄 lib/data/repository/file_repository.dart
class FileRepository extends BaseRemoteRepository {
Future<FileModel?> upload({
required List<int> bytes,
required String filename,
required String mimeType,
}) async {
final body = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: DioMediaType.parse(mimeType),
),
});
final response = await client.post('/api/file/upload', data: body);
if (response.statusCode == 201) {
final content = response.data['content'];
return FileModel.fromJson(content);
}
return null;
}
}
FormData
→ multipart/form-data 요청 생성
MultipartFile.fromBytes
파일의 bytes
filename
contentType (mimeType) 필수
-성공 시 (201 Created) → FileModel 반환
📄 test/file_repository_test.dart
void main() {
final fileRepository = FileRepository();
test('FileRepository : upload test', () async {
final file = File('assets/images/welcome.png');
final bytes = await file.readAsBytes();
final result = await fileRepository.upload(
filename: 'welcome.png',
mimeType: 'image/png',
bytes: bytes,
);
expect(result != null, true);
expect(result?.originName, 'welcome.png');
print(result?.toJson());
});
}
테스트는 현재 PC 파일 시스템 기준
File.readAsBytes()로 파일을 byte 배열로 변환
업로드 성공 여부 및 응답 데이터 검증
📄 lib/data/model/user.dart
class User {
final int id;
final String username;
final String nickname;
final FileModel profileImage;
User({
required this.id,
required this.username,
required this.nickname,
required this.profileImage,
});
// JSON → 객체
User.fromJson(Map<String, dynamic> map)
: this(
id: map["id"],
username: map["username"],
nickname: map["nickname"],
profileImage: FileModel.fromJson(map["profileImage"]),
);
// 객체 → JSON
Map<String, dynamic> toJson() => {
"id": id,
"username": username,
"nickname": nickname,
"profileImage": profileImage.toJson(),
};
}
서버 응답 구조 그대로 모델링
profileImage는 FileModel 재사용
회원 정보 조회(myInfo) 응답용 모델
📄 lib/data/repository/user_repository.dart
class UserRepository extends BaseRemoteRepository {
Future<bool> login({
required String username,
required String password,
}) async {
final response = await client.post(
'/login',
data: {'username': username, 'password': password},
);
return response.statusCode == 200;
}
// 정보 조회
Future<User?> myInfo() async {
final response = await client.get('/api/user/myInfo');
if (response.statusCode == 200) {
return User.fromJson(response.data['content']);
}
return null;
}
// 아이디 중복 체크
Future<bool> usernameCk(String username) async {
final response = await client.get(
'/api/user/usernameCk',
queryParameters: {'username': username},
);
return response.statusCode == 200;
}
// 회원가입
Future<bool> join({
required String username,
required String nickname,
required String password,
required String addressFullName,
required int profileImageId,
}) async {
final response = await client.post(
'/api/user/join',
data: {
'username': username,
'nickname': nickname,
'password': password,
'addressFullName': addressFullName,
'profileImageId': profileImageId,
},
);
return response.statusCode == 201;
}
// 닉네임 중복 체크
Future<bool> nicknameCk(String nickname) async {
final response = await client.get(
'/api/user/nicknameCk',
queryParameters: {'nickname': nickname},
);
return response.statusCode == 200;
}
}
| 메서드 | 설명 | 성공 기준 |
|---|---|---|
login | 로그인 | 200 |
usernameCk | 아이디 중복 체크 | 200 (사용 가능) |
nicknameCk | 닉네임 중복 체크 | 200 (사용 가능) |
join | 회원가입 | 201 |
myInfo | 내 정보 조회 | 200 |
myInfo는 로그인 후에만 가능
📄 test/user_repository_test.dart
void main() {
final userRepository = UserRepository();
test('login test', () async {
final case1 = await userRepository.login(
username: 'tester',
password: '1112',
);
expect(case1, false);
final case2 = await userRepository.login(
username: 'tester',
password: '1111',
);
expect(case2, true);
});
test('username check test', () async {
expect(await userRepository.usernameCk('tester'), false);
expect(await userRepository.usernameCk('tester222'), true);
});
test('nickname check test', () async {
expect(await userRepository.nicknameCk('테스트'), false);
expect(await userRepository.nicknameCk('tttttt'), true);
});
test('myInfo test', () async {
// 로그인 전
final beforeLogin = await userRepository.myInfo();
expect(beforeLogin == null, true);
// 로그인 후
final loginResult = await userRepository.login(
username: 'tester',
password: '1111',
);
expect(loginResult, true);
final afterLogin = await userRepository.myInfo();
expect(afterLogin?.username, 'tester');
});
// 회원가입 테스트는 중복 이슈로 제외
}
flutter pub add image_picker
ios/Runner/Info.plist
<key>NSPhotoLibraryUsageDescription</key>
<string>사진 업로드를 위해 라이브러리 권한을 허용해 주세요</string>
flutter pub add mime
image_picker로 가져온 이미지의 mimeType이 항상 null
mime 패키지는 파일 바이트를 분석해서 mime 타입 추출
📁 lib/core/image_picker_helper.dart
class PickImageResult {
final String filename;
final String mimeType;
final Uint8List bytes;
PickImageResult({
required this.filename,
required this.mimeType,
required this.bytes,
});
}
class ImagePickerHelper {
// ImagePicker로 사진 불러와서
// mime 패키지로 mimeType 읽은 후 함께 돌려줘야 하기 때문에
static Future<PickImageResult?> pickImage() async {
final imagePicker = ImagePicker();
final xFile = await imagePicker.pickImage(source: ImageSource.gallery);
if (xFile != null) {
final bytes = await xFile.readAsBytes();
final mimeType = lookupMimeType(xFile.path, headerBytes: bytes);
// mimeType이 없으면 이미지가 아님
if (mimeType == null) return null;
return PickImageResult(
filename: xFile.name,
mimeType: mimeType,
bytes: bytes,
);
}
return null;
}
}
lookupMimeType() → 파일 바이트 기준으로 mime 타입 추출
mime 타입이 없으면 업로드 불가 이미지
상태 설계
텍스트 입력: TextFormField가 관리
ViewModel에서는 이미지(FileModel?)만 관리
📁 lib/ui/pages/join/join_view_model.dart
class JoinViewModel extends Notifier<FileModel?> {
FileModel? build() {
return null;
}
final fileRepository = FileRepository();
final userRepository = UserRepository();
// 사진 업로드
Future<void> uploadImage({
required String filename,
required String mimeType,
required Uint8List bytes,
}) async {
final fileModel = await fileRepository.upload(
filename: filename,
mimeType: mimeType,
bytes: bytes,
);
state = fileModel;
}
// 회원가입
Future<bool> join({
required String username,
required String password,
required String nickname,
required String addressFullName,
}) async {
if (state == null) return false;
return await userRepository.join(
username: username,
nickname: nickname,
password: password,
addressFullName: addressFullName,
profileImageId: state!.id,
);
}
}
final joinViewModel =
NotifierProvider.autoDispose<JoinViewModel, FileModel?>(
() => JoinViewModel(),
);
이미지 업로드 성공 시 state에 FileModel 저장
회원가입 시 profileImageId로 전달
주요 상태
TextEditingController → 닉네임 / 아이디 / 비밀번호
Form + GlobalKey<FormState>
void onImageUpload(WidgetRef ref) async {
final result = await ImagePickerHelper.pickImage();
if (result != null) {
final viewModel = ref.read(joinViewModel.notifier);
viewModel.uploadImage(
filename: result.filename,
mimeType: result.mimeType,
bytes: result.bytes,
);
}
}
void onJoin(WidgetRef ref) async {
if (formKey.currentState?.validate() ?? false) {
final viewModel = ref.read(joinViewModel.notifier);
final result = await viewModel.join(
username: idController.text,
password: pwController.text,
nickname: nicknameController.text,
addressFullName: widget.address,
);
if (result && mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => WelcomePage()),
(route) => false,
);
} else if (result == false && mounted) {
SnackbarUtils.showSnackBr(context, '회원가입에 실패했습니다.');
}
}
}
final imageFile = ref.watch(joinViewModel);
이미지 있음 → Image.network
이미지 없음 → 기본 아이콘 표시
child: imageFile != null
? ClipRRect(
borderRadius: BorderRadiusGeometry.circular(100),
child: Image.network(
imageFile.url,
fit: BoxFit.cover,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person, size: 30),
SizedBox(height: 2),
Text('프로필 사진', style: TextStyle(fontSize: 12)),
],
),
ListView 안에서 Image 사용 시 부모 Container를 Align으로 감싸서 크기 확장 방지
// 아이디와 닉네임 중복 체크
Future<String?> validateName({
required String username,
required String nickname,
}) async {
final idResult = await userRepository.usernameCk(username);
if (!idResult) {
return "사용할 수 없는 아이디입니다.";
}
final nickResult = await userRepository.usernameCk(username);
if (!nickResult) {
return "사용할 수 없는 닉네임입니다.";
}
return null;
}
final validateResult = await viewModel.validateName(
username: idController.text,
nickname: nicknameController.text,
);
중복 검사 로직 추가
오늘 5강 절반정도 들어서 내일 5강 마무리하고 과제 시작하면 좋을 듯 허다