Last active
July 10, 2023 13:01
-
-
Save jinyongp/7a51cd7685929c94d82ca0b707f69516 to your computer and use it in GitHub Desktop.
Flutter Watcha Pedia App
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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