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(),
),
);
}
}
스트 체크리스트
- 백엔드가 실행 중인지 확인 (
flask서버 5000 포트) lib/config.dart의baseUrl이 환경에 맞는지 확인- 에뮬레이터 →
http://10.0.2.2:5000 - 실기기 → PC의 로컬 IP로 교체
- 에뮬레이터 →
- 실제 사진 선택 후, 주제 목록이 보이는지 → 카테고리 선택 → 글 초안 생성 → 복사까지 동작 확
트러블슈팅
- 아이디어 주제가 비어있음
- 백엔드 Part 4 응답이
{"ideas": "<JSON string>"}형식인지 확인 - 프론트에서
jsonDecode(ideasRaw)2단계 파싱이 들어가 있는지 확인
- 백엔드 Part 4 응답이
- 네트워크 에러 (실기기)
- PC와 폰이 같은 네트워크인지, 방화벽이 막지 않는지 확인
baseUrl을http://<PC-IP>:5000으로 변경
- 카메라 권한
- Android
CAMERA권한 확인
- Android
다음 단계 (Part 7 예고)
- 생성 결과를 SQLite/로컬DB에 저장
- History 화면에서 이전 생성물 목록/검색/재사용 UX 구현
댓글 남기기