사전 준비
AGP 이유 해결
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.5.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
}
의존성 추가
image_picker: ^1.1.2
gal: ^2.3.2
http: ^1.5.0
styles/app_style.dart
import 'package:flutter/material.dart';
/// 앱에서 사용되는 모든 스타일과 상수를 관리하는 클래스
/// 일관된 디자인을 위해 중앙에서 관리
class AppStyles {
// 인스턴스 생성 방지
AppStyles._();
// === 색상 상수 ===
static const Color primaryColor = Colors.blue;
static const Color cameraButtonColor = Colors.blue;
static const Color galleryButtonColor = Colors.green;
static const Color saveButtonColor = Colors.orange;
static const Color backgroundColor = Colors.white;
// === 텍스트 스타일 ===
static const TextStyle titleStyle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
);
static const TextStyle noImageStyle = TextStyle(
fontSize: 16,
color: Colors.grey,
);
// === 패딩과 마진 ===
static const double defaultPadding = 16.0;
static const double buttonSpacing = 12.0;
static const double iconSize = 24.0;
// === 문자열 상수 ===
static const String defaultMessage = '사진을 선택하거나 촬영하세요';
static const String cameraLoading = '카메라를 준비 중...';
static const String galleryLoading = '갤러리를 여는 중...';
static const String saveLoading = '이미지를 저장 중...';
static const String saveSuccess = '이미지가 저장되었습니다!';
static const String noImageText = '이미지가 없습니다';
}
helpers/image_helper.dart
// 이미지 처리와 서버 업로드를 담당하는 헬퍼 클래스(비즈니스 클래스)
// 카메라 촬영, 갤러리 선택, 로컬 저장, 서버 업로드 기능 제공
import 'dart:convert';
import 'dart:io';
import 'package:gal/gal.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:http/http.dart' as http;
class ImageHelper {
static const String _baseUrl = 'http://192.168.0.132:8080';
static const String _uploadEndpoint = '/api/images';
// 카메라 사진 촬영
static Future<File?> takePhoto() async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 100,
);
if (image != null) {
return File(image.path);
}
} catch (e) {
print('카메라 촬영 오류 $e');
return null;
}
}
// 갤러리에서 이미지 선택
static Future<File?> pickFromGallery() async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 100,
);
if (image != null) {
return File(image.path);
}
} catch (e) {
print('갤러리 이미지 선택 오류 $e');
return null;
}
}
// 로컬 갤러리에 이미지 저장 기능
static Future<bool> saveToGallery(String imagePath) async {
try {
// 갤러리 접근 권한 여부 확인
if (await Gal.hasAccess() == false) {
await Gal.requestAccess();
}
await Gal.putImage(imagePath);
return true;
} catch (e) {
print('갤러리 저장 중 오류 $e');
return false;
}
}
// 서버로 이미지 업로드 (Base64 접두사 포함)
static Future<Map<String, dynamic>> uploadToServer(File imageFile) async {
try {
// 1. 파일을 바이트 단위로 읽어준다.
final List<int> bytes = await imageFile.readAsBytes();
// 2. 바이트 데이터를 Base64로 인코딩 처리
final String base64String = base64Encode(bytes);
// 3. 파일 확장자로 이미지 MIME Type 설정
String extension = path.extension(imageFile.path).toLowerCase();
String mimeType = 'image/jpeg';
switch (extension) {
case '.png':
mimeType = 'image/png';
break;
case '.jpg':
case '.jpeg':
mimeType = 'image/jpeg';
break;
case '.gif':
mimeType = 'image/gif';
break;
default:
mimeType = 'image/jpeg';
}
// 4. Base64 웹 표준 형식
// 'data:image/png;base64,ARGDf....'
final String imageDataWithPrefix = 'data:$mimeType;base64,$base64String';
// 5. fileName 준비 해야 함 (서버측 약속)
final String fileName = path.basename(imageFile.path);
// 서버측으로 보낼 데이터 형식 준비
// data --? json 으로 만들어 보내는 방식 : Map 구조로 만들어서 처리
final Map<String, dynamic> requestData = {
'fileName': fileName,
'imageData': imageDataWithPrefix
};
// 7. http post 통신 요청
final http.Response response = await http.post(
Uri.parse('$_baseUrl$_uploadEndpoint'),
headers: <String, String>{
'Content-Type': 'application/json;charset=utf-8',
},
body: jsonEncode(requestData),
);
if (response.statusCode == 200) {
// 성공 시 응답 본문 JSON을 Map 구조로 파싱 처리
// response.body -- 단순히 문자열{"fileName","a.png"}
Map<String, dynamic> responseData = jsonDecode(response.body);
// print(responseData['fileName']); --> fromMap(Map) --> object
return <String, dynamic>{
'success': true,
'message': '이미지가 성공적으로 업로드 되었습니다',
'data': responseData
};
} else {
return <String, dynamic>{
'success': false,
'message': '서버 오류 ${response.statusCode}',
'data': response.body
};
}
} catch (e) {
print('서버 업로드 오류 발생 : $e');
return <String, dynamic>{
'success': false,
'message': '서버 오류',
'data': e.toString()
};
}
}
// 서버에서 전체 이미지 리스트 조회 기능
static Future<Map<String, dynamic>> getImageList() async {
try {
http.Response response = await http.get(
Uri.parse('$_baseUrl$_uploadEndpoint'),
headers: {'Content-Type': 'application/json;char-set=utf-8'});
if (response.statusCode == 200) {
final List<dynamic> imageList = jsonDecode(response.body);
return {
'success': true,
'data': imageList,
};
} else {
return {
'success': false,
'message': '목록 조회 실패',
};
}
} catch (e) {
return {
'success': false,
'message': '목록 조회 실패',
};
}
}
// 서버에 특정 한개 이미지 조회 기능
static Future<Map<String, dynamic>> getImageDetail(int imageId) async {
try {
http.Response response = await http.get(
Uri.parse('$_baseUrl$_uploadEndpoint/$imageId'),
headers: {'Content-Type': 'application/json;char-set=utf-8'});
if (response.statusCode == 200) {
final imageData = jsonDecode(response.body);
return {
'success': true,
'data': imageData,
};
} else {
return {
'success': false,
'message': '이미지 조회 실패',
};
}
} catch (e) {
return {
'success': false,
'message': '이미지 조회 실패',
};
}
}
}
screens/camera_screen.dart
import 'dart:io';
import 'package:class_camera_v2/helpers/image_helper.dart';
import 'package:class_camera_v2/styles/app_style.dart';
import 'package:flutter/material.dart';
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen> {
File? _selectedImage;
String statusMessage = "사진을 선택하거나 촬영하세요";
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('카메라 앱'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
body: SafeArea(
child: Column(
children: [
Container(
width: double.infinity,
color: Colors.grey[300],
padding: const EdgeInsets.all(16.0),
child: Text(
statusMessage,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
Expanded(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8.0),
),
child: _isLoading
? Center(child: CircularProgressIndicator())
: _selectedImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(
_selectedImage!,
fit: BoxFit.cover,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 80,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
"이미지가 없습니다",
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
),
),
Container(
padding: const EdgeInsets.all(16.0),
child: Wrap(
spacing: 12.0,
runSpacing: 8.0,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : _takePhoto,
icon: const Icon(Icons.camera_alt),
label: const Text('카메라'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
ElevatedButton.icon(
onPressed: _isLoading ? null : _pickFromGallery,
icon: const Icon(Icons.photo_library),
label: const Text('갤러리'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
ElevatedButton.icon(
onPressed: (_selectedImage != null && !_isLoading)
? _saveFromGallery
: null,
icon: const Icon(Icons.photo_library),
label: const Text('저장'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
ElevatedButton.icon(
onPressed: (_selectedImage != null && !_isLoading)
? _uploadToServer
: null,
icon: const Icon(Icons.photo_library),
label: const Text('서버로 전송'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
)
],
),
)
],
),
),
),
);
}
Future<void> _takePhoto() async {
setState(() {
_isLoading = false;
statusMessage = AppStyles.cameraLoading;
});
final File? image = await ImageHelper.takePhoto();
setState(() {
_isLoading = false;
if (image != null) {
_selectedImage = image;
statusMessage = "카메라로 사진 촬영 완료";
} else {
statusMessage = "사진 촬영이 취소";
}
_resetMessageAfterDelay();
});
}
Future<void> _pickFromGallery() async {
setState(() {
_isLoading = false;
statusMessage = AppStyles.cameraLoading;
});
final File? image = await ImageHelper.pickFromGallery();
setState(() {
_isLoading = false;
if (image != null) {
_selectedImage = image;
statusMessage = "갤러리에서 이미지 선택 완료";
} else {
statusMessage = "이미지 선택 취소";
}
_resetMessageAfterDelay();
});
}
void _saveFromGallery() async {
setState(() {
_isLoading = false;
statusMessage = AppStyles.saveLoading;
});
await ImageHelper.saveToGallery(_selectedImage!.path);
_resetMessageAfterDelay();
}
Future<void> _uploadToServer() async {
// 방어적 코드
if (_selectedImage == null) return;
setState(() {
_isLoading = true;
statusMessage = '서버에 업로드 중';
});
// Map<String, dynamic>
final result = await ImageHelper.uploadToServer(_selectedImage!);
setState(() {
_isLoading = false;
if (result['success'] == true) {
statusMessage = result['message'];
} else {
statusMessage = result['message'];
}
});
_resetMessageAfterDelay();
}
// 일정 시간 후 상태 메세지를 기본값으로 돌리는 기능
void _resetMessageAfterDelay() {
Future.delayed(const Duration(seconds: 3), () {
// mounted는 Flutter의 State 클래스에서 제공하는 bool 타입 변수이다.
// 현재 위젯이 화면에 아직 존재하는지(활성 상태)를 확인하는 변수
if (mounted) {
setState(() {
statusMessage = AppStyles.defaultMessage;
});
}
});
}
} // end of class
screens/image_detail_screen.dart
import 'dart:convert';
import 'package:class_camera_v2/helpers/image_helper.dart';
import 'package:flutter/material.dart';
class ImageDetailScreen extends StatefulWidget {
const ImageDetailScreen({super.key});
@override
State<ImageDetailScreen> createState() => _ImageDetailScreenState();
}
class _ImageDetailScreenState extends State<ImageDetailScreen> {
late Future<dynamic> _imageDetailFuture;
int? _imageId;
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_imageId = ModalRoute.of(context)?.settings.arguments as int?;
print('_imageId : $_imageId');
// id 값이 있어야 통신 호출 가능 하다.
if (_imageId != null) {
_imageDetailFuture = ImageHelper.getImageDetail(_imageId!);
}
}
// 리스트에서 넘겨준 데이터를 받는 코드를 작성하고 즉, id 값으로
// 다시 http.get 요청으로 상세보기 데이터를 받아서 화면을 그려주면 된다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('이미지 상세 보기'),
),
body: FutureBuilder(
future: _imageDetailFuture,
builder: (context, snapshot) {
// 1. 로딩중
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('이미지 불러오기 실패'));
} else {
// 서버에서 넘어온 base64 다루는 방법
final imageData = snapshot.data!['data'];
final String base64String = imageData['imageData'];
// 접두사 제거
final cleanBase64 = base64String.split(',').last;
final bytes = base64Decode(cleanBase64);
return Container(
child: Image.memory(
bytes,
fit: BoxFit.cover,
),
);
}
},
),
);
}
}
screens/image_list_screen.dart
import 'dart:convert';
import 'package:class_camera_v2/helpers/image_helper.dart';
import 'package:class_camera_v2/styles/app_style.dart';
import 'package:flutter/material.dart';
class ImageListScreen extends StatefulWidget {
const ImageListScreen({super.key});
@override
State<ImageListScreen> createState() => _ImageListScreenState();
}
class _ImageListScreenState extends State<ImageListScreen> {
late Future<List<dynamic>> _imageListFuture;
@override
void initState() {
super.initState();
// 단 한번만 호출할 수 있도록 보장
_imageListFuture = _fetchImageList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('서버 이미지 목록'),
backgroundColor: AppStyles.primaryColor,
foregroundColor: Colors.white,
),
body: FutureBuilder<List<dynamic>>(
future: _imageListFuture,
builder: (context, snapshot) {
// 1. 로딩 중 상태
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// 2. 오류 발생 상태
else if (snapshot.hasError) {
return Center(
child: Text(
'데이터 불러오기 실패',
textAlign: TextAlign.center,
),
);
}
// 3. 데이터가 없는 경우
else if (snapshot.hasData == false || snapshot.data!.isEmpty) {
return const Center(
child: Text('서버에 저장된 이미지가 없습니다.'),
);
}
// 4. 데이터가 있는 경우
else {
final List<dynamic> imageList = snapshot.data!;
// ListView.builder 사용자가 보고 있는 화면을 기준으로
return ListView.builder(
itemCount: imageList.length,
itemBuilder: (context, index) {
final image = imageList[index];
return _buildImageList(image);
},
);
}
},
),
);
}
Widget _buildImageList(dynamic image) {
final id = image['id'] as int;
final fileName = image['fileName'] as String;
final base64String = image['imageData'] as String;
// 'data:image/png;base64, 접두사 까지 넘어옴
final cleanBase64 = base64String.split(',').last;
final bytes = base64Decode(cleanBase64);
return Card(
margin: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
elevation: 2,
child: ListTile(
leading: Image.memory(
bytes,
fit: BoxFit.contain,
),
title: Text(fileName),
onTap: () {
// 상세보기 화면으로 이동하는 처리
// 화면 이동 시 추가적으로 통신 요청을 한번 더 한다
// 화면 이동 시 데이터를 전달할 수 있다.
Navigator.pushNamed(context, "/detail", arguments: id);
},
),
);
}
// 서버에서 이미지 목록 가져오는 함수
Future<List<dynamic>> _fetchImageList() async {
final result = await ImageHelper.getImageList();
if (result['success'] == true) {
return result['data'] as List<dynamic>;
} else {
throw Exception(result['message']);
}
}
}
main.dart
import 'package:class_camera_v2/screens/camera_screen.dart';
import 'package:class_camera_v2/screens/image_list_screen.dart';
import 'package:class_camera_v2/screens/image_detail_screen.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '카메라 앱',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
initialRoute: '/list',
routes: {
'/': (context) => const CameraScreen(),
'/list': (context) => const ImageListScreen(),
'/detail': (context) => const ImageDetailScreen(),
},
);
}
}