Skip to content

Commit 01ab6eb

Browse files
authored
Merge pull request #7 from BCTP001/AI
Add AI Recommendation Feed
2 parents 6fee316 + 3941fef commit 01ab6eb

3 files changed

Lines changed: 351 additions & 2 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_android-2.2.15/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]}],"date_created":"2025-07-03 12:54:47.528311","version":"3.32.5","swift_package_manager_enabled":{"ios":false,"macos":false}}
1+
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_android-2.2.15/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/codespace/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-3.0.6/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]}],"date_created":"2025-07-06 12:59:33.626964","version":"3.32.1","swift_package_manager_enabled":{"ios":false,"macos":false}}

myapp/lib/component/graphql_client.dart

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
44
class GraphQLService {
55
static ValueNotifier<GraphQLClient>? _client;
66
static const String _baseUrl =
7-
"https://humble-zebra-jww5wr76jr53jg4w-4000.app.github.dev/";
7+
"https://redesigned-carnival-xp6v4wpj9pw2jv-4000.app.github.dev/";
88
/// Get or initialize GraphQL client
99
static ValueNotifier<GraphQLClient> getClient() {
1010
if (_client == null) {
@@ -263,4 +263,45 @@ class GraphQLService {
263263
return [];
264264
}
265265
}
266+
267+
static Future<List<dynamic>> recommendBooks(String keyword, int topN) async {
268+
final GraphQLClient client = getClient().value;
269+
270+
const String recommendBooksQuery = '''
271+
query ExampleQuery(\$request: RecommendBooksRequest) {
272+
recommendBooks(request: \$request) {
273+
author
274+
category
275+
cover
276+
description
277+
title
278+
}
279+
}
280+
''';
281+
282+
final QueryOptions options = QueryOptions(
283+
document: gql(recommendBooksQuery),
284+
variables: {
285+
'request': {"keyword": keyword,
286+
"top_n": topN}
287+
},
288+
);
289+
290+
try {
291+
final QueryResult result = await client.query(options);
292+
if (result.hasException) {
293+
debugPrint('GraphQL Error: ${result.exception.toString()}');
294+
return [];
295+
}
296+
297+
if (result.data != null && result.data!['recommendBooks'] != null) {
298+
return result.data!['recommendBooks'] as List<dynamic>;
299+
}
300+
301+
return [];
302+
} catch (e) {
303+
debugPrint('Error in recommendBooks: $e');
304+
return [];
305+
}
306+
}
266307
}

myapp/lib/content/AIfeed.dart

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:google_fonts/google_fonts.dart';
3+
import 'package:graphql_flutter/graphql_flutter.dart';
4+
import '../component/graphql_client.dart';
5+
6+
void main() => runApp(const MaterialApp(home: BookRecommendationContent()));
7+
8+
class BookRecommendationContent extends StatefulWidget {
9+
const BookRecommendationContent({super.key});
10+
11+
@override
12+
State<BookRecommendationContent> createState() =>
13+
_BookRecommendationContentState();
14+
}
15+
16+
class _BookRecommendationContentState extends State<BookRecommendationContent>
17+
with SingleTickerProviderStateMixin {
18+
String selectedKeyword = '';
19+
final List<String> keywords = [
20+
"로맨스", "힐링", "자기계발", "과학", "주식", "코딩", "판타지",
21+
"만화", "정치", "사회", "요리", "문학", "성장", "청소년", "세계", "미군"
22+
];
23+
24+
final TextEditingController _searchController = TextEditingController();
25+
26+
List<dynamic> recommendedBooks = [];
27+
bool isLoading = false;
28+
29+
@override
30+
void initState() {
31+
super.initState();
32+
33+
// 검색창 입력이 바뀔 때마다 setState() 호출해서 버튼 상태 업데이트
34+
_searchController.addListener(() {
35+
setState(() {});
36+
});
37+
}
38+
39+
@override
40+
Widget build(BuildContext context) {
41+
return DefaultTabController(
42+
length: 2,
43+
child: Scaffold(
44+
backgroundColor: const Color(0xFFB7FFE3),
45+
appBar: AppBar(
46+
backgroundColor: const Color(0xFFB7FFE3),
47+
elevation: 0,
48+
centerTitle: true,
49+
title: Text(
50+
'오늘의 책',
51+
style: GoogleFonts.nanumBrushScript(
52+
fontSize: 40,
53+
color: Colors.black,
54+
),
55+
),
56+
bottom: const TabBar(
57+
labelColor: Colors.black,
58+
unselectedLabelColor: Colors.black45,
59+
indicatorColor: Colors.black,
60+
tabs: [
61+
Tab(text: '키워드'),
62+
Tab(text: '도서'),
63+
],
64+
),
65+
),
66+
body: TabBarView(
67+
children: [
68+
buildKeywordTab(),
69+
buildBookSearchTab(),
70+
],
71+
),
72+
bottomNavigationBar: BottomAppBar(
73+
color: const Color(0xFFFDFCE5),
74+
child: Padding(
75+
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 10),
76+
child: Row(
77+
mainAxisAlignment: MainAxisAlignment.spaceAround,
78+
children: const [
79+
Icon(Icons.home, size: 28),
80+
Icon(Icons.search, size: 28),
81+
Icon(Icons.bookmark, size: 28),
82+
Icon(Icons.settings, size: 28),
83+
],
84+
),
85+
),
86+
),
87+
),
88+
);
89+
}
90+
91+
Widget buildKeywordTab() {
92+
return Padding(
93+
padding: const EdgeInsets.all(16.0),
94+
child: Column(
95+
crossAxisAlignment: CrossAxisAlignment.start,
96+
children: [
97+
const Text("키워드로 추천 받기",
98+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
99+
const SizedBox(height: 12),
100+
Wrap(
101+
spacing: 8,
102+
runSpacing: 10,
103+
children: keywords.map((keyword) {
104+
final isSelected = selectedKeyword == keyword;
105+
return ChoiceChip(
106+
label: Text(keyword),
107+
selected: isSelected,
108+
onSelected: (_) {
109+
setState(() {
110+
selectedKeyword = isSelected ? '' : keyword;
111+
});
112+
},
113+
selectedColor: const Color(0xFF5D3A00),
114+
backgroundColor: Colors.white,
115+
labelStyle: TextStyle(
116+
color: isSelected ? Colors.white : Colors.black,
117+
),
118+
);
119+
}).toList(),
120+
),
121+
const Spacer(),
122+
Center(
123+
child: ElevatedButton(
124+
onPressed: selectedKeyword.isEmpty ? null : () => fetchRecommendations(selectedKeyword),
125+
style: ElevatedButton.styleFrom(
126+
backgroundColor: const Color(0xFF5D3A00),
127+
padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 16),
128+
),
129+
child: isLoading
130+
? const CircularProgressIndicator(color: Colors.white)
131+
: const Text("책 추천 받기",
132+
style: TextStyle(fontSize: 18, color: Colors.white)),
133+
),
134+
),
135+
],
136+
),
137+
);
138+
}
139+
140+
Widget buildBookSearchTab() {
141+
return Padding(
142+
padding: const EdgeInsets.all(16.0),
143+
child: Column(
144+
crossAxisAlignment: CrossAxisAlignment.start,
145+
children: [
146+
const Text("도서 검색", style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
147+
const SizedBox(height: 12),
148+
TextField(
149+
controller: _searchController,
150+
decoration: InputDecoration(
151+
hintText: "검색어를 입력하세요",
152+
filled: true,
153+
fillColor: Colors.white,
154+
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
155+
),
156+
),
157+
const Spacer(),
158+
Center(
159+
child: ElevatedButton(
160+
onPressed: _searchController.text.trim().isEmpty
161+
? null
162+
: () => fetchRecommendations(_searchController.text.trim()),
163+
style: ElevatedButton.styleFrom(
164+
backgroundColor: const Color(0xFF5D3A00),
165+
padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 16),
166+
),
167+
child: isLoading
168+
? const CircularProgressIndicator(color: Colors.white)
169+
: const Text("책 추천 받기",
170+
style: TextStyle(fontSize: 18, color: Colors.white)),
171+
),
172+
),
173+
],
174+
),
175+
);
176+
}
177+
178+
Future<void> fetchRecommendations(String keyword) async {
179+
setState(() {
180+
isLoading = true;
181+
});
182+
183+
final ValueNotifier<GraphQLClient> client = GraphQLService.getClient();
184+
185+
try {
186+
final books = await GraphQLService.recommendBooks(keyword, 5);
187+
setState(() {
188+
recommendedBooks = books;
189+
isLoading = false;
190+
});
191+
192+
Navigator.push(
193+
context,
194+
MaterialPageRoute(builder: (_) => RecommendationResultScreen(books: recommendedBooks)),
195+
);
196+
} catch (e) {
197+
setState(() {
198+
isLoading = false;
199+
});
200+
debugPrint('추천 실패: $e');
201+
}
202+
}
203+
}
204+
205+
class RecommendationResultScreen extends StatelessWidget {
206+
final List<dynamic> books;
207+
const RecommendationResultScreen({super.key, required this.books});
208+
209+
@override
210+
Widget build(BuildContext context) {
211+
return Scaffold(
212+
backgroundColor: const Color(0xFF9AD9B8),
213+
appBar: AppBar(
214+
backgroundColor: const Color(0xFF9AD9B8),
215+
elevation: 0,
216+
leading: IconButton(
217+
icon: const Icon(Icons.arrow_back),
218+
onPressed: () => Navigator.pop(context),
219+
),
220+
),
221+
body: books.isEmpty
222+
? const Center(child: Text("추천 결과가 없습니다."))
223+
: Column(
224+
crossAxisAlignment: CrossAxisAlignment.start,
225+
children: [
226+
Row(
227+
mainAxisAlignment: MainAxisAlignment.center,
228+
children: [Text(
229+
"키워드를 바탕으로\n도서를 찾아보았어요",
230+
style: GoogleFonts.nanumBrushScript(
231+
fontSize: 40,
232+
color: Colors.black,
233+
),
234+
textAlign: TextAlign.center,
235+
),
236+
],
237+
),
238+
const SizedBox(height: 16),
239+
Expanded(
240+
child: ListView.builder(
241+
itemCount: books.length,
242+
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
243+
itemBuilder: (context, index) {
244+
final book = books[index];
245+
return _buildBookCard(context, book);
246+
},
247+
),
248+
),
249+
],
250+
),
251+
);
252+
}
253+
254+
Widget _buildBookCard(BuildContext context, dynamic book) {
255+
return Card(
256+
color: Color(0xFFF5F5DC),
257+
margin: const EdgeInsets.symmetric(vertical: 50),
258+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
259+
child: Padding(
260+
padding: const EdgeInsets.all(20.0),
261+
child: Column(
262+
children: [
263+
_buildBookCover(book['author']),
264+
const SizedBox(height: 12),
265+
Text(
266+
book['title'] ?? '제목 없음',
267+
style: GoogleFonts.jua(
268+
fontSize: 20,
269+
fontWeight: FontWeight.bold,
270+
),
271+
),
272+
const SizedBox(height: 6),
273+
Text(
274+
book['description'],
275+
style: GoogleFonts.jua(
276+
fontSize: 16,
277+
),
278+
),
279+
Text(
280+
book['cover'],
281+
style: GoogleFonts.jua(
282+
fontSize: 16,
283+
color: Color(0xFF037549),
284+
),
285+
),
286+
],
287+
),
288+
),
289+
);
290+
}
291+
292+
Widget _buildBookCover(String? coverUrl) {
293+
return ClipRRect(
294+
borderRadius: BorderRadius.circular(8.0),
295+
child: coverUrl != null
296+
? Image.network(
297+
coverUrl,
298+
width: 200,
299+
height: 280,
300+
fit: BoxFit.contain,
301+
errorBuilder: (context, error, stackTrace) {
302+
return const Icon(Icons.book, size: 100);
303+
},
304+
)
305+
: const Icon(Icons.book, size: 100),
306+
);
307+
}
308+
}

0 commit comments

Comments
 (0)