Skip to content

Instantly share code, notes, and snippets.

@jinyongp
Last active July 10, 2023 13:01
Show Gist options
  • Save jinyongp/7a51cd7685929c94d82ca0b707f69516 to your computer and use it in GitHub Desktop.
Save jinyongp/7a51cd7685929c94d82ca0b707f69516 to your computer and use it in GitHub Desktop.
Flutter Watcha Pedia App
import 'dart:async';
import 'dart:convert';
// import 'package:dio/dio.dart';
import 'package:http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
late SharedPreferences pref;
if (!kIsWeb) {
WidgetsFlutterBinding.ensureInitialized();
pref = await SharedPreferences.getInstance();
}
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => BookService(
get: <T>(String query) async {
String url =
'https://www.googleapis.com/books/v1/volumes?q=$query&startIndex=0&maxResults=40';
// Response res = await Dio().get(url);
// if (res.statusCode != 200) {
// throw Exception('http.get error: statusCode= ${res.statusCode}');
// }
// return res.data['items'];
var client = Client();
try {
var res = await client.get(Uri.parse(url));
if (res.statusCode != 200) {
throw Exception('http.get error: statusCode= ${res.statusCode}');
}
print(jsonDecode(res.body)['items']);
return jsonDecode(res.body)['items'] ?? [] as T;
} catch (error) {
client.close();
return [] as T;
}
},
save: (String payload) => pref.setString('likedBooks', payload),
load: () => pref.getString('likedBooks'),
),
),
],
child: const MainApp(),
));
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
var currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: [const SearchScreen(), const LikeScreen()].elementAt(currentIndex),
bottomNavigationBar: BottomNavigationBar(
iconSize: 28,
selectedFontSize: 12,
unselectedFontSize: 12,
selectedItemColor: Colors.black,
unselectedItemColor: Colors.grey,
showUnselectedLabels: true,
type: BottomNavigationBarType.fixed,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.search), label: '검색'),
BottomNavigationBarItem(icon: Icon(Icons.star), label: '좋아요'),
],
currentIndex: currentIndex,
onTap: (index) => setState(() => currentIndex = index),
),
);
}
}
class LikeScreen extends StatefulWidget {
const LikeScreen({super.key});
@override
State<LikeScreen> createState() => _LikeScreenState();
}
class _LikeScreenState extends State<LikeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Consumer<BookService>(
builder: (context, bookService, child) {
if (bookService.likedBooks.isEmpty) {
return const Center(
child: Text(
"좋아요를 눌러 저장해보세요.",
style: TextStyle(fontSize: 16, color: Colors.grey),
),
);
}
return ListView.separated(
itemCount: bookService.likedBooks.length,
itemBuilder: (context, index) =>
BookTile(book: bookService.likedBooks.elementAt(index)),
separatorBuilder: (context, index) => const Divider(),
);
},
),
);
}
}
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
@override
Widget build(BuildContext context) {
BookService bookService = context.read<BookService>();
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
toolbarHeight: 80,
title: TextField(
onSubmitted: (query) {
bookService.search(query);
},
cursorColor: Colors.grey,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search, color: Colors.grey),
hintText: '작품, 감독, 배우, 컬렉션, 유저 등',
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white),
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
),
),
),
body: Consumer<BookService>(builder: (context, bookService, child) {
return Stack(
children: [
bookService.loading
? const LinearProgressIndicator()
: const SizedBox(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: bookService.loading
? centerText("결과를 불러오는 중입니다...")
: bookService.books.isEmpty
? centerText("검색어를 입력해주세요.")
: ListView.separated(
itemCount: bookService.books.length,
itemBuilder: (context, index) => BookTile(
book: bookService.books.elementAt(index)),
separatorBuilder: (context, index) => const Divider(),
),
),
],
);
}),
);
}
Widget centerText(String text) {
return Center(
heightFactor: 30,
child: Text(
text,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
);
}
}
// ignore: must_be_immutable
class WebViewScreen extends StatelessWidget {
WebViewScreen({
super.key,
required this.url,
});
String url;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.grey,
title: Text(url),
),
body: const Center(
child: Text('WebView Not Supported in DartPad'),
),
);
}
}
class BookTile extends StatefulWidget {
const BookTile({
super.key,
required this.book,
});
final Book book;
@override
State<BookTile> createState() => _BookTileState();
}
class _BookTileState extends State<BookTile> {
@override
Widget build(BuildContext context) {
BookService bookService = context.read<BookService>();
Book book = widget.book;
return ListTile(
leading: SizedBox(
width: 50,
height: 50,
child: Image.network(
book.thumbnail,
fit: BoxFit.cover,
),
),
title: Text(
book.title,
style: const TextStyle(fontSize: 16),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.authors?.join(', ') ?? 'Unknown',
style: const TextStyle(fontSize: 12, color: Colors.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
book.publishedDate ?? 'Unknown',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
trailing: IconButton(
icon: bookService.isLiked(book.id)
? const Icon(Icons.star_rounded, color: Colors.amber)
: const Icon(Icons.star_outline_rounded),
onPressed: () {
bookService.toggleLike(book);
},
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WebViewScreen(
url: book.previewLink.replaceFirst('http:', 'https:')),
),
);
},
);
}
}
class Book {
String id;
String title;
String subtitle;
List<String>? authors;
String? publishedDate;
String thumbnail;
String previewLink;
Book({
required this.id,
required this.title,
required this.subtitle,
required this.authors,
required this.publishedDate,
required this.thumbnail,
required this.previewLink,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'subtitle': subtitle,
'authors': authors,
'publishedDate': publishedDate,
'thumbnail': thumbnail,
'previewLink': previewLink,
};
}
factory Book.fromJson(Map<String, dynamic> json) {
return Book(
id: json['id'],
title: json['title'],
subtitle: json['subtitle'],
authors: json['authors']?.cast<String>(),
publishedDate: json['publishedDate'],
thumbnail: json['thumbnail'],
previewLink: json['previewLink'],
);
}
}
class BookService extends ChangeNotifier {
final List<Book> _books = [];
final List<Book> _likedBooks = [];
final Future<T> Function<T>(String url) get;
final FutureOr Function(String payload)? save;
final FutureOr Function()? load;
BookService({required this.get, this.save, this.load}) {
_load().then((_) => notifyListeners());
}
List<Book> get books => List.unmodifiable(_books);
List<Book> get likedBooks => List.unmodifiable(_likedBooks);
bool loading = false;
Future<void> search(String query) async {
if (query.isEmpty) return;
notifyListeners();
loading = true;
_books.clear();
List<dynamic> items = await get(query);
for (Map<String, dynamic> item in items) {
_books.add(Book(
id: item['id'],
title: item['volumeInfo']?['title'] ?? '',
subtitle: item['volumeInfo']?['subtitle'] ?? '',
authors: item['volumeInfo']?['authors']?.cast<String>(),
publishedDate: item['volumeInfo']?['publishedDate'],
thumbnail: item['volumeInfo']?['imageLinks']?['thumbnail'] ??
'https://thumbs.dreamstime.com/b/no-image-available-icon-flat-vector-no-image-available-icon-flat-vector-illustration-132482953.jpg',
previewLink: item['volumeInfo']?['previewLink'] ?? '',
));
}
loading = false;
notifyListeners();
}
bool isLiked(String id) {
return _likedBooks.any((book) => book.id == id);
}
void toggleLike(Book book) {
String id = book.id;
if (isLiked(id)) {
_likedBooks.removeWhere((book) => book.id == id);
} else {
_likedBooks.add(book);
}
_save();
notifyListeners();
}
Future<void> _save() async {
if (save == null) return;
var payload = _likedBooks.map((book) => book.toJson()).toList();
await save!(jsonEncode(payload));
}
Future<void> _load() async {
if (load == null) return;
var payload = await load!();
if (payload == null) return;
_likedBooks.clear();
List<dynamic> data = jsonDecode(payload);
for (Map<String, dynamic> item in data) {
_likedBooks.add(Book.fromJson(item));
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment