Flutter

카메라 및 이미지 다루기 4단계

whs5758 2025. 8. 20. 17:10

사전 준비

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(),
      },
    );
  }
}

'Flutter' 카테고리의 다른 글

SharedPreferences  (0) 2025.08.20
구글 Map API 사용  (0) 2025.08.20
카메라 다루기 3단계  (0) 2025.08.20
카메라 다루기 2단계  (0) 2025.08.20
카메라 다루기 1단계  (0) 2025.08.20