본문 바로가기
핫이슈

플러터로 앱만들기 회원가입 및 사진올리기 기능

by 웨더맨 2025. 7. 9.
반응형

플러터로 앱만들기   회원가입 및 사진올리기 기능

  1. 회원가입 화면: 이메일과 비밀번호를 입력하고 '가입하기' 버튼을 누르면 메인 화면으로 이동합니다. (실제 서버에 저장되지는 않는 UI 데모입니다.)
  2. 메인 화면:
    • 갤러리에서 사진을 선택하는 기능
    • 선택된 사진에 대한 소개글을 작성하는 기능
    • '올리기' 버튼을 누르면 사진과 소개글이 화면 하단 리스트에 추가되는 기능

아래 단계를 따라 하시면 바로 앱을 실행해볼 수 있습니다.


1단계: Flutter 프로젝트 생성 및 설정

  1. 프로젝트 생성: 터미널이나 명령 프롬프트에서 아래 명령어를 실행하여 새 프로젝트를 만듭니다.생성된 bash
    flutter create photo_app
    cd photo_app
  2. 필요한 패키지 추가: 사진을 갤러리에서 가져오기 위해 패키지가 필요합니다. 파일을 열고 섹션에 아래와 같이 추가해 주세요.image_pickerpubspec.yaml종속성:생성된 yaml
    dependencies:
      flutter:
        sdk: flutter
      cupertino_icons: ^1.0.2
      image_picker: ^1.0.7 # 이 줄을 추가하세요
  3. 패키지 설치: 파일을 저장한 후, 터미널에서 아래 명령어를 실행합니다.생성된 bash
    flutter pub get
  4. 플랫폼별 권한 설정 (매우 중요!): 를 사용하려면 각 OS에 사진첩 접근 권한을 요청해야 합니다.image_picker
    • iOS 설정: 파일을 열고 태그 안에 다음 코드를 추가하세요.ios/러너/Info.plist<딕셔너리>생성된 xml
      <key>NSPhotoLibraryUsageDescription</key>
      <string>앱에서 사진을 선택하기 위해 앨범 접근 권한이 필요합니다.</string>
      <key>NSCameraUsageDescription</key>
      <string>앱에서 사진을 찍기 위해 카메라 접근 권한이 필요합니다.</string>
      <key>NSMicrophoneUsageDescription</key>
      <string>앱에서 비디오를 녹화하기 위해 마이크 접근 권한이 필요합니다.</string>
    • Android 설정: 안드로이드는 별도 설정이 필요하지 않지만, 앱이 API 33 이상을 타겟팅하는 경우 파일에 다음 권한을 추가하는 것이 좋습니다.android/app/src/main/AndroidManifest.xml생성된 xml
      <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

2단계: 전체소스 코드 작성

생성된 다트

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

// 앱 시작점
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '간단 사진 소개 앱',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      // 앱이 시작될 때 보여줄 첫 화면
      home: const SignUpScreen(),
    );
  }
}

// 회원가입 화면 위젯
class SignUpScreen extends StatefulWidget {
  const SignUpScreen({super.key});

  @override
  State<SignUpScreen> createState() => _SignUpScreenState();
}

class _SignUpScreenState extends State<SignUpScreen> {
  // 텍스트 필드를 제어하기 위한 컨트롤러
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('회원가입'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _emailController,
              decoration: const InputDecoration(
                labelText: '이메일',
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.emailAddress,
            ),
            const SizedBox(height: 16.0),
            TextField(
              controller: _passwordController,
              decoration: const InputDecoration(
                labelText: '비밀번호',
                border: OutlineInputBorder(),
              ),
              obscureText: true, // 비밀번호를 가려줌
            ),
            const SizedBox(height: 24.0),
            ElevatedButton(
              onPressed: () {
                // '가입하기' 버튼을 누르면 메인 화면으로 이동
                // 실제 앱에서는 여기서 유효성 검사 및 서버 통신이 필요
                Navigator.pushReplacement( // pushReplacement를 사용해 뒤로가기 시 회원가입 화면이 나오지 않게 함
                  context,
                  MaterialPageRoute(builder: (context) => const MainScreen()),
                );
              },
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16.0),
              ),
              child: const Text('가입하고 시작하기', style: TextStyle(fontSize: 16)),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    // 위젯이 화면에서 사라질 때 컨트롤러를 정리
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}


// 게시물을 표현하는 데이터 클래스
class Post {
  final File image;
  final String description;

  Post({required this.image, required this.description});
}

// 메인 화면 위젯
class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  // 선택된 이미지를 저장할 변수 (nullable)
  File? _image;
  // 소개글을 제어하기 위한 컨트롤러
  final TextEditingController _descriptionController = TextEditingController();
  // 게시물 목록을 저장할 리스트
  final List<Post> _posts = [];
  // 이미지 피커 인스턴스
  final ImagePicker _picker = ImagePicker();

  // 갤러리에서 이미지를 가져오는 함수
  Future<void> _pickImage() async {
    try {
      final XFile? pickedFile = await _picker.pickImage(source: ImageSource.gallery);

      if (pickedFile != null) {
        setState(() {
          _image = File(pickedFile.path);
        });
      }
    } catch (e) {
      // 에러 처리
      print("이미지 선택 중 오류 발생: $e");
    }
  }

  // 게시물을 리스트에 추가하는 함수
  void _addPost() {
    if (_image != null && _descriptionController.text.isNotEmpty) {
      setState(() {
        _posts.insert(0, Post(image: _image!, description: _descriptionController.text)); // 새 게시물을 맨 앞에 추가
        _image = null; // 선택된 이미지 초기화
        _descriptionController.clear(); // 텍스트 필드 초기화
      });
      // 키보드 숨기기
      FocusScope.of(context).unfocus();
    } else {
      // 사용자에게 사진이나 소개글이 필요하다고 알림
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('사진과 소개를 모두 입력해주세요!')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('사진 소개'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 사진 선택 및 소개 입력 영역
            GestureDetector(
              onTap: _pickImage,
              child: Container(
                height: 200,
                width: double.infinity,
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: _image == null
                    ? const Center(child: Text('여기를 눌러 사진을 선택하세요', style: TextStyle(color: Colors.grey)))
                    : ClipRRect(
                        borderRadius: BorderRadius.circular(12),
                        child: Image.file(_image!, fit: BoxFit.cover),
                ),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _descriptionController,
              decoration: const InputDecoration(
                hintText: '사진에 대한 소개를 입력하세요...',
                border: OutlineInputBorder(),
              ),
              maxLines: 3,
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _addPost,
              style: ElevatedButton.styleFrom(
                minimumSize: const Size(double.infinity, 50), // 버튼 크기
              ),
              child: const Text('소개 올리기'),
            ),
            const Divider(height: 32, thickness: 1),

            // 게시물 목록 영역
            const Text('내 소개 목록', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 10),
            Expanded(
              child: _posts.isEmpty
                  ? const Center(child: Text('아직 등록된 소개가 없습니다.'))
                  : ListView.builder(
                      itemCount: _posts.length,
                      itemBuilder: (context, index) {
                        final post = _posts[index];
                        return Card(
                          margin: const EdgeInsets.symmetric(vertical: 8.0),
                          child: Padding(
                            padding: const EdgeInsets.all(12.0),
                            child: Row(
                              children: [
                                // 게시물 이미지
                                ClipRRect(
                                  borderRadius: BorderRadius.circular(8),
                                  child: Image.file(
                                    post.image,
                                    width: 100,
                                    height: 100,
                                    fit: BoxFit.cover,
                                  ),
                                ),
                                const SizedBox(width: 16),
                                // 게시물 설명
                                Expanded(
                                  child: Text(
                                    post.description,
                                    style: const TextStyle(fontSize: 16),
                                  ),
                                ),
                              ],
                            ),
                          ),
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _descriptionController.dispose();
    super.dispose();
  }
}

3단계: 앱 실행

모든 설정을 마쳤다면, 터미널에서 아래 명령어를 실행하여 앱을 빌드하고 실행합니다.

Generated bash

flutter run

코드에 대한 간단한 설명

  1. main.dart: 앱의 시작점입니다. 을 설정하고 첫 화면으로 을 지정합니다.MaterialAppSignUpScreen
  2. SignUpScreen: 간단한 회원가입 UI입니다. 2개와 1개로 구성되어 있습니다. 버튼을 누르면 를 통해 으로 이동하며, 이전 화면(회원가입)으로 돌아올 수 없게 만듭니다.TextFieldElevatedButtonNavigator.pushReplacementMainScreen
  3. MainScreen: 핵심 기능이 들어있는 화면입니다.
    • _image: 선택된 이미지 파일을 저장하는 타입 변수입니다.File
    • _posts: 객체(이미지+설명)를 담는 리스트입니다.Post
    • _pickImage(): 패키지를 사용해 갤러리에서 이미지를 가져와 변수를 업데이트하는 비동기 함수입니다.image_picker_이미지
    • _addPost(): 현재 선택된  의 텍스트를 이용해 객체를 만들어 리스트에 추가합니다. 그 후, 입력 필드를 초기화합니다._이미지_descriptionController올리기_게시물
    • 빌드() 메서드: 화면 UI를 구성합니다.
      • 을 사용하여 위젯을 세로로 배치합니다.
      • 제스처 감지기로 감싼 를 통해 사진 선택 영역을 만듭니다.컨테이너
      • 확장 를 사용하여 리스트에 있는 모든 게시물을 동적으로 화면에 그려줍니다. 이는 게시물이 많아져도 화면을 벗어나지 않고 스크롤이 가능하게 해줍니다.리스트뷰.빌더_게시물

참고: 이 코드는 로컬 기기 내에서만 동작합니다. 앱을 껐다 켜면 올렸던 사진과 글이 사라집니다. 데이터를 영구적으로 저장하려면 Firebase의 Firestore(데이터베이스)와 Storage(파일 저장) 같은 백엔드 서비스 연동이 필요합니다.

728x90
반응형

댓글