Flutter 연동: 사진 업로드 → 주제 선택 → 글 생성 → 복사

이번 편에서 완성되는 것

  • 결과 화면에서 클립보드 복사
  • image_picker로 사진 촬영/선택
  • http.MultipartRequest로 Flask /api/upload-image 업로드
  • /api/generate-ideas 결과에서 주제 목록 추출 및 표시
  • 카테고리 선택 후 /api/generate-text글 초안 생성

의존성 & 기본 설정

1) pubspec.yaml

flutter에서 최신 버전 확인 후 추

name: ideasnap_app
description: IdeaSnap Flutter Frontend
environment:
  sdk: ">=3.3.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.2
  image_picker: ^1.1.2
  provider: ^6.1.2

flutter:
  uses-material-design: true

2) 백엔드 주소 설정

개발용 로컬(Android 에뮬레이터)에서는 http://10.0.2.2:5000을 사용하세요.
실기기/동일 Wi-Fi라면 컴퓨터 IP(예: http://192.168.0.12:5000)로 바꾸면 됩니다.

// lib/config.dart
class AppConfig {
  static const String baseUrl = "http://10.0.2.2:5000"; // 필요시 변경

3) Android

Android (android/app/src/main/AndroidManifest.xml)필요 시 AndroidManifest.xml 에 추가

<manifest ...>
  <application ...>
    <!-- image_picker는 보통 추가 권한 없이 동작하지만, 카메라를 강제 사용하려면 필요 -->
  </application>
  <uses-permission android:name="android.permission.CAMERA" />
</manifest>

파일 구조 (이번 편에서 추가)

lib/
 ├─ config.dart
 ├─ main.dart
 ├─ services/
 │   └─ api_service.dart
 ├─ providers/
 │   └─ idea_provider.dart
 └─ screens/
     ├─ home_screen.dart
     ├─ idea_screen.dart
     └─ result_screen.dart

API 서비스

// lib/services/api_service.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../config.dart';

class ApiService {
  final String base = AppConfig.baseUrl;

  Future<String> uploadImage(File file) async {
    final uri = Uri.parse("$base/api/upload-image");
    final req = http.MultipartRequest("POST", uri)
      ..files.add(await http.MultipartFile.fromPath("image", file.path));
    final resp = await req.send();
    final body = await resp.stream.bytesToString();
    if (resp.statusCode == 200) {
      final json = jsonDecode(body);
      return json["file_path"] as String; // "uploads/xxx.jpg"
    } else {
      throw Exception("Image upload failed: $body");
    }
  }

  /// 백엔드 Part4는 {"ideas": "<JSON string>"} 형태를 반환하므로
  /// 내부의 문자열을 다시 jsonDecode 해야 합니다.
  Future<Map<String, dynamic>> generateIdeas(String imagePath) async {
    final uri = Uri.parse("$base/api/generate-ideas");
    final resp = await http.post(
      uri,
      headers: {"Content-Type": "application/json"},
      body: jsonEncode({"image_path": imagePath}),
    );
    if (resp.statusCode == 200) {
      final outer = jsonDecode(resp.body);
      final ideasRaw = outer["ideas"];
      // ideasRaw가 문자열(JSON string)인지, 이미 Map인지 대비
      if (ideasRaw is String) {
        return jsonDecode(ideasRaw) as Map<String, dynamic>;
      } else if (ideasRaw is Map<String, dynamic>) {
        return ideasRaw;
      } else {
        throw Exception("Unexpected ideas format");
      }
    } else {
      throw Exception("generate-ideas failed: ${resp.body}");
    }
  }

  Future<String> generateText({
    required String topic,
    required String category,
    List<String> keywords = const [],
  }) async {
    final uri = Uri.parse("$base/api/generate-text");
    final resp = await http.post(
      uri,
      headers: {"Content-Type": "application/json"},
      body: jsonEncode({
        "topic": topic,
        "category": category,
        "keywords": keywords,
      }),
    );
    if (resp.statusCode == 200) {
      final json = jsonDecode(resp.body);
      return (json["generated_text"] as String?)?.trim() ?? "";
    } else {
      throw Exception("generate-text failed: ${resp.body}");
    }
  }
}

상태 관리 (Provider)

// lib/providers/idea_provider.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../services/api_service.dart';

class IdeaProvider extends ChangeNotifier {
  final ApiService api = ApiService();

  File? selectedImage;
  String? uploadedPath; // e.g., "uploads/sample.jpg"
  String description = "";
  List<String> keywords = [];
  List<String> topics = [];
  String? selectedTopic;
  String category = "블로그 게시물";
  String generatedText = "";
  bool loading = false;
  String? error;

  Future<void> setImage(File file) async {
    selectedImage = file;
    uploadedPath = null;
    description = "";
    keywords = [];
    topics = [];
    selectedTopic = null;
    generatedText = "";
    error = null;
    notifyListeners();
  }

  Future<void> uploadAndFetchIdeas() async {
    if (selectedImage == null) return;
    loading = true; error = null; notifyListeners();
    try {
      uploadedPath = await api.uploadImage(selectedImage!);
      final ideaJson = await api.generateIdeas(uploadedPath!);
      description = (ideaJson["description"] as String?) ?? "";
      keywords = (ideaJson["keywords"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
      topics = (ideaJson["topics"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
    } catch (e) {
      error = e.toString();
    } finally {
      loading = false; notifyListeners();
    }
  }

  Future<void> generateDraft() async {
    if (selectedTopic == null) {
      error = "주제를 선택해주세요.";
      notifyListeners();
      return;
    }
    loading = true; error = null; notifyListeners();
    try {
      generatedText = await api.generateText(
        topic: selectedTopic!,
        category: category,
        keywords: keywords,
      );
    } catch (e) {
      error = e.toString();
    } finally {
      loading = false; notifyListeners();
    }
  }
}

화면 구성

1) 메인: 사진 선택/촬영

// lib/screens/home_screen.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../providers/idea_provider.dart';
import 'idea_screen.dart';

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

  Future<void> _pick(BuildContext context, ImageSource src) async {
    final picker = ImagePicker();
    final x = await picker.pickImage(source: src, imageQuality: 85);
    if (x != null) {
      await context.read<IdeaProvider>().setImage(File(x.path));
      // 다음 화면으로 이동
      // 업로드 & 아이디어 생성은 다음 화면에서 트리거
      // (사용자에게 로딩 과정을 보여주기 위해)
      // ignore: use_build_context_synchronously
      Navigator.push(context, MaterialPageRoute(builder: (_) => const IdeaScreen()));
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(title: const Text("IdeaSnap")),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(mainAxisSize: MainAxisSize.min, children: [
            Icon(Icons.image_outlined, size: 72, color: theme.colorScheme.primary),
            const SizedBox(height: 16),
            const Text("사진을 업로드하거나 촬영하여 아이디어를 생성하세요."),
            const SizedBox(height: 24),
            FilledButton.icon(
              icon: const Icon(Icons.photo_library_outlined),
              label: const Text("갤러리에서 선택"),
              onPressed: () => _pick(context, ImageSource.gallery),
            ),
            const SizedBox(height: 12),
            OutlinedButton.icon(
              icon: const Icon(Icons.photo_camera_outlined),
              label: const Text("카메라로 촬영"),
              onPressed: () => _pick(context, ImageSource.camera),
            ),
          ]),
        ),
      ),
    );
  }
}

2) 아이디어: 업로드 → 주제 선택 → 카테고리 선택

// lib/screens/idea_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/idea_provider.dart';
import 'result_screen.dart';

class IdeaScreen extends StatefulWidget {
  const IdeaScreen({super.key});

  @override
  State<IdeaScreen> createState() => _IdeaScreenState();
}

class _IdeaScreenState extends State<IdeaScreen> {
  @override
  void initState() {
    super.initState();
    // 이미지 업로드 + 아이디어 생성 실행
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<IdeaProvider>().uploadAndFetchIdeas();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Consumer<IdeaProvider>(
      builder: (context, p, _) {
        return Scaffold(
          appBar: AppBar(title: const Text("아이디어 선택")),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: p.loading
                ? const Center(child: CircularProgressIndicator())
                : p.error != null
                    ? Center(child: Text("오류: ${p.error}"))
                    : Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          if (p.description.isNotEmpty) ...[
                            Text("사진 설명", style: Theme.of(context).textTheme.titleMedium),
                            const SizedBox(height: 8),
                            Text(p.description),
                            const SizedBox(height: 16),
                          ],
                          if (p.keywords.isNotEmpty) ...[
                            Text("키워드", style: Theme.of(context).textTheme.titleMedium),
                            const SizedBox(height: 8),
                            Wrap(
                              spacing: 8,
                              children: p.keywords.map((k) => Chip(label: Text(k))).toList(),
                            ),
                            const SizedBox(height: 16),
                          ],
                          Text("주제 선택", style: Theme.of(context).textTheme.titleMedium),
                          const SizedBox(height: 8),
                          DropdownButton<String>(
                            isExpanded: true,
                            value: p.selectedTopic,
                            hint: const Text("주제를 선택하세요"),
                            items: p.topics.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
                            onChanged: (v) => setState(() => p.selectedTopic = v),
                          ),
                          const SizedBox(height: 16),
                          Text("카테고리", style: Theme.of(context).textTheme.titleMedium),
                          const SizedBox(height: 8),
                          DropdownButton<String>(
                            value: p.category,
                            items: const [
                              "블로그 게시물",
                              "짧은 보고서",
                              "아이디어 노트",
                              "시"
                            ].map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
                            onChanged: (v) => setState(() => p.category = v ?? "블로그 게시물"),
                          ),
                          const Spacer(),
                          FilledButton.icon(
                            icon: const Icon(Icons.auto_stories_outlined),
                            label: const Text("글 초안 생성"),
                            onPressed: p.selectedTopic == null
                                ? null
                                : () async {
                                    await p.generateDraft();
                                    if (p.error == null) {
                                      // ignore: use_build_context_synchronously
                                      Navigator.push(context, MaterialPageRoute(builder: (_) => const ResultScreen()));
                                    }
                                  },
                          )
                        ],
                      ),
          ),
        );
      },
    );
  }
}

3) 결과: 글 초안 + 복사

// lib/screens/result_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/idea_provider.dart';

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

  @override
  Widget build(BuildContext context) {
    final p = context.watch<IdeaProvider>();
    return Scaffold(
      appBar: AppBar(title: const Text("생성된 글")),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: p.loading
            ? const Center(child: CircularProgressIndicator())
            : p.error != null
                ? Center(child: Text("오류: ${p.error}"))
                : Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        p.selectedTopic ?? "주제",
                        style: Theme.of(context).textTheme.titleLarge,
                      ),
                      const SizedBox(height: 8),
                      Text("카테고리: ${p.category}"),
                      const SizedBox(height: 16),
                      Expanded(
                        child: SingleChildScrollView(
                          child: SelectableText(
                            p.generatedText.isEmpty ? "생성된 내용이 없습니다." : p.generatedText,
                          ),
                        ),
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: [
                          OutlinedButton.icon(
                            icon: const Icon(Icons.copy),
                            label: const Text("복사"),
                            onPressed: p.generatedText.isEmpty
                                ? null
                                : () async {
                                    await Clipboard.setData(ClipboardData(text: p.generatedText));
                                    if (context.mounted) {
                                      ScaffoldMessenger.of(context).showSnackBar(
                                        const SnackBar(content: Text("클립보드에 복사했습니다.")),
                                      );
                                    }
                                  },
                          ),
                          const SizedBox(width: 12),
                          TextButton(
                            child: const Text("처음으로"),
                            onPressed: () => Navigator.of(context).popUntil((r) => r.isFirst),
                          ),
                        ],
                      )
                    ],
                  ),
      ),
    );
  }
}

엔트리포인트

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/idea_provider.dart';
import 'screens/home_screen.dart';

void main() {
  runApp(const IdeaSnapApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => IdeaProvider(),
      child: MaterialApp(
        title: 'IdeaSnap',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF3E8BFF)),
          useMaterial3: true,
        ),
        home: const HomeScreen(),
      ),
    );
  }
}

스트 체크리스트

  1. 백엔드가 실행 중인지 확인 (flask 서버 5000 포트)
  2. lib/config.dartbaseUrl이 환경에 맞는지 확인
    • 에뮬레이터 → http://10.0.2.2:5000
    • 실기기 → PC의 로컬 IP로 교체
  3. 실제 사진 선택 후, 주제 목록이 보이는지카테고리 선택글 초안 생성복사까지 동작 확

트러블슈팅

  • 아이디어 주제가 비어있음
    • 백엔드 Part 4 응답이 {"ideas": "<JSON string>"} 형식인지 확인
    • 프론트에서 jsonDecode(ideasRaw) 2단계 파싱이 들어가 있는지 확인
  • 네트워크 에러 (실기기)
    • PC와 폰이 같은 네트워크인지, 방화벽이 막지 않는지 확인
    • baseUrlhttp://<PC-IP>:5000으로 변경
  • 카메라 권한
    • Android CAMERA 권한 확인

다음 단계 (Part 7 예고)

  • 생성 결과를 SQLite/로컬DB에 저장
  • History 화면에서 이전 생성물 목록/검색/재사용 UX 구현

TechTinkerer's에서 더 알아보기

구독을 신청하면 최신 게시물을 이메일로 받아볼 수 있습니다.

댓글 남기기

  • Working with Files in Python: Reading and Writing Data

    [Tutorial] · 2026-01-19 05:35 UTC ## Working with Files in Python: Reading and Writing Data #### 💡 TL;DR Python’s ‘with’ statement simplifies file operations, enabling efficient reading and writing of data from/to text files. ### 📚 Learning Objectives **This tutorial introduces file handling basics using Python, focusing on reading and writing data to/from text files.…

  • Understanding Data Structures: Lists, Tuples, and Dictionaries

    [Tutorial] · 2026-01-19 04:33 UTC ## Understanding Data Structures: Lists, Tuples, and Dictionaries #### 💡 TL;DR Learn about the core concepts of lists, tuples, and dictionaries for efficient data storage in your programs. ### 📚 Learning Objectives **This tutorial explains lists, tuples, and dictionaries – fundamental data structures in programming. We’ll explore their properties, how…

  • 6. Flutter에서 사용자 입력 및 폼 처리

    사용자 입력을 처리하고, Flutter에서 Form을 사용해 유효성 검사를 구현하는 방법을 설명합니다.

  • 5. Flutter의 상태관리: StatelessWidget & StatefulWidget

    Flutter의 StatelessWidget과 StatefulWidget을 비교하고 동적 UI 구현 방법을 설명합니다.

  • 4. Flutter의 기본 위젯: Text, Button, Row, Column

    Flutter의 기본 위젯들을 활용하여 간단한 UI 레이아웃을 구성하는 방법을 설명합니다.

← 뒤로

응답해 주셔서 감사합니다. ✨

TechTinkerer's에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기

TechTinkerer's에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기