├── res └── values │ └── strings_en.arb ├── lib ├── domain │ ├── cache_policy.dart │ ├── favorited_books_repo.dart │ ├── book_repo.dart │ ├── toggle_fav_result.dart │ ├── book.dart │ ├── toggle_fav_result.g.dart │ └── book.g.dart ├── data │ ├── mappers.dart │ ├── api │ │ ├── book_api.dart │ │ └── book_response.dart │ ├── favorited_books_repo_impl.dart │ └── book_repo_impl.dart ├── utils │ └── custome_built_value_to_string_helper.dart ├── app.dart ├── pages │ ├── detail_page │ │ ├── detail_state.dart │ │ ├── detail_bloc.dart │ │ ├── detail_state.g.dart │ │ └── detail_page.dart │ ├── fav_page │ │ ├── fav_books_state.dart │ │ ├── fav_books_bloc.dart │ │ ├── fav_books_state.g.dart │ │ └── fav_books_page.dart │ └── home_page │ │ ├── home_bloc.dart │ │ ├── home_state.g.dart │ │ ├── home_state.dart │ │ └── home_page.dart ├── widgets │ └── fav_count_badge.dart ├── main.dart └── generated │ └── i18n.dart ├── assets ├── no_image.png ├── Poppins-Regular.ttf └── NunitoSans-Regular.ttf ├── ios ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ ├── flutter_export_environment.sh │ ├── Flutter.podspec │ └── AppFrameworkInfo.plist ├── Runner │ ├── AppDelegate.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── main.m │ ├── AppDelegate.m │ ├── Info.plist │ └── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ └── contents.xcworkspacedata ├── .gitignore └── Podfile ├── android ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── app │ ├── src │ │ └── main │ │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── values │ │ │ │ └── styles.xml │ │ │ └── drawable │ │ │ │ └── launch_background.xml │ │ │ ├── java │ │ │ └── com │ │ │ │ └── hoc │ │ │ │ └── searchbook │ │ │ │ └── MainActivity.java │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── .gitignore ├── settings.gradle ├── build.gradle ├── gradlew.bat └── gradlew ├── .idea ├── vcs.xml ├── modules.xml ├── libraries │ ├── Flutter_Plugins.xml │ ├── Dart_SDK.xml │ └── Dart_Packages.xml └── workspace.xml ├── .github └── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug_report.md ├── .metadata ├── .flutter-plugins-dependencies ├── test ├── widget_test.dart └── shared_pref_test.dart ├── pubspec.yaml ├── LICENSE ├── .gitignore ├── README.md └── pubspec.lock /res/values/strings_en.arb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/domain/cache_policy.dart: -------------------------------------------------------------------------------- 1 | enum CachePolicy { networkOnly, localFirst } 2 | -------------------------------------------------------------------------------- /assets/no_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/assets/no_image.png -------------------------------------------------------------------------------- /assets/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/assets/Poppins-Regular.ttf -------------------------------------------------------------------------------- /assets/NunitoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/assets/NunitoSans-Regular.ttf -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.enableR8=true 3 | android.useAndroidX=true 4 | android.enableJetifier=true 5 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @interface AppDelegate : FlutterAppDelegate 5 | 6 | @end 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.class 3 | .gradle 4 | /local.properties 5 | /.idea/workspace.xml 6 | /.idea/libraries 7 | .DS_Store 8 | /build 9 | /captures 10 | GeneratedPluginRegistrant.java 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/search-book-flutter-BLoC-pattern-rxdart/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/main.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import "AppDelegate.h" 4 | 5 | int main(int argc, char * argv[]) { 6 | @autoreleasepool { 7 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Dec 24 09:26:24 ICT 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: f9bb4289e9fd861d70ae78bcc3a042ef1b35cc9d 8 | channel: beta 9 | -------------------------------------------------------------------------------- /.flutter-plugins-dependencies: -------------------------------------------------------------------------------- 1 | {"_info":"// This is a generated file; do not edit or check into version control.","dependencyGraph":[{"name":"shared_preferences","dependencies":["shared_preferences_macos","shared_preferences_web"]},{"name":"shared_preferences_macos","dependencies":[]},{"name":"shared_preferences_web","dependencies":[]}]} -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/domain/favorited_books_repo.dart: -------------------------------------------------------------------------------- 1 | import 'package:rxdart/rxdart.dart'; 2 | import 'package:built_collection/built_collection.dart'; 3 | import 'package:search_book/domain/toggle_fav_result.dart'; 4 | 5 | abstract class FavoritedBooksRepo { 6 | Future toggleFavorited(String bookId); 7 | 8 | ValueStream> get favoritedIds$; 9 | } 10 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/libraries/Flutter_Plugins.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/hoc/searchbook/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.hoc.searchbook; 2 | 3 | import android.os.Bundle; 4 | 5 | import io.flutter.app.FlutterActivity; 6 | import io.flutter.plugins.GeneratedPluginRegistrant; 7 | 8 | public class MainActivity extends FlutterActivity { 9 | @Override 10 | protected void onCreate(Bundle savedInstanceState) { 11 | super.onCreate(savedInstanceState); 12 | GeneratedPluginRegistrant.registerWith(this); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.m: -------------------------------------------------------------------------------- 1 | #include "AppDelegate.h" 2 | #include "GeneratedPluginRegistrant.h" 3 | 4 | @implementation AppDelegate 5 | 6 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { 7 | [GeneratedPluginRegistrant registerWithRegistry:self]; 8 | // Override point for customization after application launch. 9 | return [super application:application didFinishLaunchingWithOptions:launchOptions]; 10 | } 11 | 12 | @end 13 | -------------------------------------------------------------------------------- /lib/domain/book_repo.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:search_book/domain/book.dart'; 4 | import 'package:search_book/domain/cache_policy.dart'; 5 | 6 | abstract class BookRepo { 7 | Future> searchBook({ 8 | @required String query, 9 | int startIndex: 0, 10 | }); 11 | 12 | Stream getBookBy({ 13 | @required String id, 14 | CachePolicy cachePolicy: CachePolicy.networkOnly, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /lib/domain/toggle_fav_result.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | 3 | part 'toggle_fav_result.g.dart'; 4 | 5 | abstract class ToggleFavResult 6 | implements Built { 7 | String get id; 8 | 9 | bool get added; 10 | 11 | @nullable 12 | bool get result; 13 | 14 | @nullable 15 | Object get error; 16 | 17 | ToggleFavResult._(); 18 | 19 | factory ToggleFavResult([void Function(ToggleFavResultBuilder) updates]) = 20 | _$ToggleFavResult; 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // To perform an interaction with a widget in your test, use the WidgetTester utility that Flutter 3 | // provides. For example, you can send tap and scroll gestures. You can also use WidgetTester to 4 | // find child widgets in the widget tree, read text, and verify that the values of widget properties 5 | // are correct. 6 | 7 | import 'package:flutter_test/flutter_test.dart'; 8 | 9 | void main() { 10 | testWidgets('Test', (WidgetTester tester) async {}); 11 | } 12 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() 4 | 5 | def plugins = new Properties() 6 | def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') 7 | if (pluginsFile.exists()) { 8 | pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } 9 | } 10 | 11 | plugins.each { name, path -> 12 | def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() 13 | include ":$name" 14 | project(":$name").projectDir = pluginDirectory 15 | } 16 | -------------------------------------------------------------------------------- /ios/Flutter/flutter_export_environment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This is a generated file; do not edit or check into version control. 3 | export "FLUTTER_ROOT=/Users/hoangtanduy/Desktop/dev/flutter" 4 | export "FLUTTER_APPLICATION_PATH=/Users/hoangtanduy/Desktop/dev/clones/search-book-flutter-BLoC-pattern-rxdart" 5 | export "FLUTTER_TARGET=lib/main.dart" 6 | export "FLUTTER_BUILD_DIR=build" 7 | export "SYMROOT=${SOURCE_ROOT}/../build/ios" 8 | export "FLUTTER_FRAMEWORK_DIR=/Users/hoangtanduy/Desktop/dev/flutter/bin/cache/artifacts/engine/ios" 9 | export "FLUTTER_BUILD_NAME=1.0.0" 10 | export "FLUTTER_BUILD_NUMBER=1" 11 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | jcenter() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:3.5.3' 9 | } 10 | } 11 | 12 | allprojects { 13 | repositories { 14 | google() 15 | jcenter() 16 | } 17 | } 18 | 19 | rootProject.buildDir = '../build' 20 | subprojects { 21 | project.buildDir = "${rootProject.buildDir}/${project.name}" 22 | } 23 | subprojects { 24 | project.evaluationDependsOn(':app') 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /lib/domain/book.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | import 'package:built_collection/built_collection.dart'; 3 | 4 | part 'book.g.dart'; 5 | 6 | abstract class Book implements Built { 7 | String get id; 8 | 9 | @nullable 10 | String get title; 11 | 12 | @nullable 13 | String get subtitle; 14 | 15 | @nullable 16 | BuiltList get authors; 17 | 18 | @nullable 19 | String get thumbnail; 20 | 21 | @nullable 22 | String get largeImage; 23 | 24 | @nullable 25 | String get description; 26 | 27 | @nullable 28 | String get publishedDate; 29 | 30 | Book._(); 31 | 32 | factory Book([updates(BookBuilder b)]) = _$Book; 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vagrant/ 3 | .sconsign.dblite 4 | .svn/ 5 | 6 | .DS_Store 7 | *.swp 8 | profile 9 | 10 | DerivedData/ 11 | build/ 12 | GeneratedPluginRegistrant.h 13 | GeneratedPluginRegistrant.m 14 | 15 | .generated/ 16 | 17 | *.pbxuser 18 | *.mode1v3 19 | *.mode2v3 20 | *.perspectivev3 21 | 22 | !default.pbxuser 23 | !default.mode1v3 24 | !default.mode2v3 25 | !default.perspectivev3 26 | 27 | xcuserdata 28 | 29 | *.moved-aside 30 | 31 | *.pyc 32 | *sync/ 33 | Icon? 34 | .tags* 35 | 36 | /Flutter/app.flx 37 | /Flutter/app.zip 38 | /Flutter/flutter_assets/ 39 | /Flutter/App.framework 40 | /Flutter/Flutter.framework 41 | /Flutter/Generated.xcconfig 42 | /ServiceDefinitions.json 43 | 44 | Pods/ 45 | .symlinks/ 46 | -------------------------------------------------------------------------------- /lib/data/mappers.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:search_book/data/api/book_response.dart'; 3 | import 'package:search_book/domain/book.dart'; 4 | 5 | typedef Mapper = R Function(T t); 6 | 7 | class Mappers { 8 | final Mapper bookResponseToDomain = (book) { 9 | return Book( 10 | (b) => b 11 | ..id = book.id 12 | ..title = book.title 13 | ..subtitle = book.subtitle 14 | ..authors = 15 | book.authors == null ? null : ListBuilder(book.authors) 16 | ..thumbnail = book.thumbnail 17 | ..largeImage = book.largeImage 18 | ..description = book.description 19 | ..publishedDate = book.publishedDate, 20 | ); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /ios/Flutter/Flutter.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: This podspec is NOT to be published. It is only used as a local source! 3 | # 4 | 5 | Pod::Spec.new do |s| 6 | s.name = 'Flutter' 7 | s.version = '1.0.0' 8 | s.summary = 'High-performance, high-fidelity mobile apps.' 9 | s.description = <<-DESC 10 | Flutter provides an easy and productive way to build and deploy high-performance mobile apps for Android and iOS. 11 | DESC 12 | s.homepage = 'https://flutter.io' 13 | s.license = { :type => 'MIT' } 14 | s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } 15 | s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } 16 | s.ios.deployment_target = '8.0' 17 | s.vendored_frameworks = 'Flutter.framework' 18 | end 19 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/utils/custome_built_value_to_string_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | 3 | class CustomBuiltValueToStringHelper implements BuiltValueToStringHelper { 4 | StringBuffer _result = StringBuffer(); 5 | bool _previousField = false; 6 | 7 | CustomBuiltValueToStringHelper(String className) { 8 | _result..write(className)..write(' {'); 9 | } 10 | 11 | @override 12 | void add(String field, Object value) { 13 | if (value != null) { 14 | if (_previousField) _result.write(','); 15 | _result 16 | ..write(field + (value is Iterable ? '.length' : '')) 17 | ..write('=') 18 | ..write(value is Iterable ? value.length : value); 19 | _previousField = true; 20 | } 21 | } 22 | 23 | @override 24 | String toString() { 25 | _result..write('}'); 26 | final stringResult = _result.toString(); 27 | _result = null; 28 | return stringResult; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: search_book 2 | description: A new Flutter project. 3 | 4 | dependencies: 5 | flutter: 6 | sdk: flutter 7 | cupertino_icons: ^0.1.2 8 | 9 | http: ^0.12.0+2 10 | collection: ^1.14.11 11 | built_value: ^7.0.0 #flutter packages pub run build_runner build 12 | sealed_unions: ^3.0.2+2 13 | tuple: ^1.0.3 14 | flutter_html: ^0.11.1 15 | 16 | flutter_provider: ^1.1.1 17 | distinct_value_connectable_stream: ^1.0.3 18 | flutter_bloc_pattern: ^1.1.0 19 | disposebag: ^1.0.0+1 20 | rx_shared_preferences: ^1.1.0 21 | 22 | dev_dependencies: 23 | flutter_test: 24 | sdk: flutter 25 | build_runner: ^1.7.2 26 | built_value_generator: ^7.0.0 27 | mockito: ^4.1.1 28 | 29 | flutter: 30 | uses-material-design: true 31 | assets: 32 | - assets/no_image.png 33 | fonts: 34 | - family: NunitoSans 35 | fonts: 36 | - asset: assets/NunitoSans-Regular.ttf 37 | - family: Poppins 38 | fonts: 39 | - asset: assets/Poppins-Regular.ttf 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Petrus Nguyễn Thái Học 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:search_book/domain/book_repo.dart'; 2 | import 'package:search_book/pages/home_page/home_bloc.dart'; 3 | import 'package:search_book/pages/home_page/home_page.dart'; 4 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter/services.dart'; 7 | import 'package:flutter_provider/flutter_provider.dart'; 8 | import 'package:search_book/domain/favorited_books_repo.dart'; 9 | 10 | class MyApp extends StatelessWidget { 11 | const MyApp({Key key}) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return MaterialApp( 16 | title: 'Search book BLoC pattern RxDart', 17 | theme: ThemeData( 18 | fontFamily: 'NunitoSans', 19 | brightness: Brightness.dark, 20 | ), 21 | home: Consumer2( 22 | builder: (context, sharedPref, bookRepo) { 23 | return BlocProvider( 24 | child: MyHomePage(), 25 | initBloc: () => HomeBloc(bookRepo, sharedPref), 26 | ); 27 | }, 28 | ), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/pages/detail_page/detail_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | import 'package:search_book/domain/book.dart'; 4 | 5 | part 'detail_state.g.dart'; 6 | 7 | abstract class BookDetailState 8 | implements Built { 9 | String get id; 10 | 11 | @nullable 12 | String get title; 13 | 14 | @nullable 15 | String get subtitle; 16 | 17 | @nullable 18 | BuiltList get authors; 19 | 20 | @nullable 21 | String get largeImage; 22 | 23 | @nullable 24 | String get thumbnail; 25 | 26 | @nullable 27 | bool get isFavorited; 28 | 29 | @nullable 30 | String get description; 31 | 32 | @nullable 33 | String get publishedDate; 34 | 35 | BookDetailState._(); 36 | 37 | factory BookDetailState([updates(BookDetailStateBuilder b)]) = 38 | _$BookDetailState; 39 | 40 | factory BookDetailState.fromDomain(Book book, [bool isFavorited]) { 41 | final authors = book.authors; 42 | return BookDetailState( 43 | (b) => b 44 | ..id = book.id 45 | ..title = book.title 46 | ..subtitle = book.subtitle 47 | ..authors = authors == null ? null : ListBuilder(authors) 48 | ..largeImage = book.largeImage 49 | ..thumbnail = book.thumbnail 50 | ..description = book.description 51 | ..publishedDate = book.publishedDate 52 | ..isFavorited = isFavorited, 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/widgets/fav_count_badge.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FavCountBadge extends StatefulWidget { 4 | final int count; 5 | 6 | const FavCountBadge({Key key, @required this.count}) : super(key: key); 7 | 8 | @override 9 | _FavCountBadgeState createState() => _FavCountBadgeState(); 10 | } 11 | 12 | class _FavCountBadgeState extends State 13 | with SingleTickerProviderStateMixin { 14 | AnimationController _animController; 15 | Animation _animation; 16 | 17 | @override 18 | void initState() { 19 | super.initState(); 20 | _animController = AnimationController( 21 | vsync: this, 22 | duration: const Duration(seconds: 2), 23 | ); 24 | _animation = Tween( 25 | begin: 0, 26 | end: 1.2, 27 | ).animate( 28 | CurvedAnimation( 29 | parent: _animController, 30 | curve: Curves.elasticOut, 31 | ), 32 | ); 33 | _animController.forward(); 34 | } 35 | 36 | @override 37 | void dispose() { 38 | _animController.dispose(); 39 | super.dispose(); 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | return ScaleTransition( 45 | scale: _animation, 46 | child: CircleAvatar( 47 | radius: 14, 48 | backgroundColor: Colors.deepOrangeAccent, 49 | child: Text( 50 | widget.count.toString(), 51 | style: Theme.of(context).textTheme.caption.copyWith(fontSize: 15), 52 | ), 53 | ), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.lock 4 | *.log 5 | *.pyc 6 | *.swp 7 | .DS_Store 8 | .atom/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # Visual Studio Code related 20 | .vscode/ 21 | 22 | # Flutter/Dart/Pub related 23 | **/doc/api/ 24 | .dart_tool/ 25 | .flutter-plugins 26 | .packages 27 | .pub-cache/ 28 | .pub/ 29 | build/ 30 | 31 | # Android related 32 | **/android/**/gradle-wrapper.jar 33 | **/android/.gradle 34 | **/android/captures/ 35 | **/android/gradlew 36 | **/android/gradlew.bat 37 | **/android/local.properties 38 | **/android/**/GeneratedPluginRegistrant.java 39 | 40 | # iOS/XCode related 41 | **/ios/**/*.mode1v3 42 | **/ios/**/*.mode2v3 43 | **/ios/**/*.moved-aside 44 | **/ios/**/*.pbxuser 45 | **/ios/**/*.perspectivev3 46 | **/ios/**/*sync/ 47 | **/ios/**/.sconsign.dblite 48 | **/ios/**/.tags* 49 | **/ios/**/.vagrant/ 50 | **/ios/**/DerivedData/ 51 | **/ios/**/Icon? 52 | **/ios/**/Pods/ 53 | **/ios/**/.symlinks/ 54 | **/ios/**/profile 55 | **/ios/**/xcuserdata 56 | **/ios/.generated/ 57 | **/ios/Flutter/App.framework 58 | **/ios/Flutter/Flutter.framework 59 | **/ios/Flutter/Generated.xcconfig 60 | **/ios/Flutter/app.flx 61 | **/ios/Flutter/app.zip 62 | **/ios/Flutter/flutter_assets/ 63 | **/ios/ServiceDefinitions.json 64 | **/ios/Runner/GeneratedPluginRegistrant.* 65 | 66 | # Exceptions to above rules. 67 | !**/ios/**/default.mode1v3 68 | !**/ios/**/default.mode2v3 69 | !**/ios/**/default.pbxuser 70 | !**/ios/**/default.perspectivev3 71 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Search book 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/data/api/book_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:http/http.dart' as http; 6 | import 'package:search_book/data/api/book_response.dart'; 7 | 8 | class BookApi { 9 | final http.Client _client; 10 | 11 | const BookApi(this._client); 12 | 13 | Future> searchBook({ 14 | String query, 15 | int startIndex: 0, 16 | }) async { 17 | print('[API] searchBook query=$query, startIndex=$startIndex'); 18 | 19 | final uri = Uri.https( 20 | 'www.googleapis.com', 21 | '/books/v1/volumes', 22 | { 23 | 'q': query, 24 | 'startIndex': startIndex.toString(), 25 | }, 26 | ); 27 | 28 | final response = await _client.get(uri); 29 | final decoded = json.decode(utf8.decode(response.bodyBytes)); 30 | 31 | if (response.statusCode != HttpStatus.ok) { 32 | throw new HttpException(decoded['error']['message']); 33 | } 34 | final items = decoded['items'] ?? []; 35 | return (items as Iterable) 36 | .cast>() 37 | .map((json) => BookResponse.fromJson(json)) 38 | .toList(); 39 | } 40 | 41 | Future getBookById(String id) async { 42 | final uri = Uri.https( 43 | 'www.googleapis.com', 44 | '/books/v1/volumes/$id', 45 | ); 46 | 47 | final response = await _client.get(uri); 48 | final decoded = json.decode(response.body); 49 | 50 | if (response.statusCode != HttpStatus.ok) { 51 | throw new HttpException(decoded['error']['message']); 52 | } 53 | 54 | return BookResponse.fromJson(decoded); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart' as built_value; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/services.dart'; 4 | import 'package:flutter_provider/flutter_provider.dart'; 5 | import 'package:http/http.dart' as http; 6 | import 'package:rx_shared_preferences/rx_shared_preferences.dart'; 7 | import 'package:search_book/app.dart'; 8 | import 'package:search_book/utils/custome_built_value_to_string_helper.dart'; 9 | import 'package:search_book/data/api/book_api.dart'; 10 | import 'package:search_book/data/book_repo_impl.dart'; 11 | import 'package:search_book/data/favorited_books_repo_impl.dart'; 12 | import 'package:search_book/data/mappers.dart'; 13 | import 'package:search_book/domain/book_repo.dart'; 14 | import 'package:search_book/domain/favorited_books_repo.dart'; 15 | import 'package:shared_preferences/shared_preferences.dart'; 16 | 17 | void main() async { 18 | WidgetsFlutterBinding.ensureInitialized(); 19 | 20 | built_value.newBuiltValueToStringHelper = 21 | (className) => CustomBuiltValueToStringHelper(className); 22 | 23 | await SystemChrome.setEnabledSystemUIOverlays([]); 24 | 25 | final bookApi = BookApi(http.Client()); 26 | final favBooksRepo = FavoritedBooksRepoImpl( 27 | RxSharedPreferences( 28 | SharedPreferences.getInstance(), 29 | ), 30 | ); 31 | final mappers = Mappers(); 32 | final bookRepo = BookRepoImpl(bookApi, mappers); 33 | 34 | runApp( 35 | Providers( 36 | providers: [ 37 | Provider(value: bookRepo), 38 | Provider(value: favBooksRepo) 39 | ], 40 | child: const MyApp(), 41 | ), 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | apply plugin: 'com.android.application' 15 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 16 | 17 | android { 18 | compileSdkVersion 29 19 | 20 | lintOptions { 21 | disable 'InvalidPackage' 22 | } 23 | 24 | defaultConfig { 25 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 26 | applicationId "com.hoc.searchbook" 27 | minSdkVersion 21 28 | targetSdkVersion 29 29 | versionCode 1 30 | versionName "1.0" 31 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 32 | } 33 | 34 | buildTypes { 35 | release { 36 | // TODO: Add your own signing config for the release build. 37 | // Signing with the debug keys for now, so `flutter run --release` works. 38 | signingConfig signingConfigs.debug 39 | } 40 | } 41 | } 42 | 43 | flutter { 44 | source '../..' 45 | } 46 | 47 | dependencies { 48 | testImplementation 'junit:junit:4.12' 49 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 50 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' 51 | } 52 | -------------------------------------------------------------------------------- /lib/data/api/book_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @immutable 5 | class BookResponse { 6 | final String id; 7 | final String title; 8 | final String subtitle; 9 | final List authors; 10 | final String thumbnail; 11 | final String largeImage; 12 | final String description; 13 | final String publishedDate; 14 | 15 | BookResponse({ 16 | @required this.id, 17 | @required this.title, 18 | @required this.subtitle, 19 | @required this.authors, 20 | @required this.thumbnail, 21 | @required this.largeImage, 22 | @required this.description, 23 | @required this.publishedDate, 24 | }); 25 | 26 | factory BookResponse.fromJson(Map json) { 27 | final volumeInfo = json['volumeInfo']; 28 | final authors = volumeInfo['authors']; 29 | final imageLinks = volumeInfo['imageLinks'] ?? {}; 30 | 31 | return BookResponse( 32 | id: json['id'], 33 | title: volumeInfo['title'], 34 | subtitle: volumeInfo['subtitle'], 35 | authors: authors?.cast(), 36 | thumbnail: imageLinks['thumbnail'], 37 | largeImage: imageLinks['small'], 38 | description: volumeInfo['description'], 39 | publishedDate: volumeInfo['publishedDate'], 40 | ); 41 | } 42 | 43 | @override 44 | String toString() => (newBuiltValueToStringHelper('Book') 45 | ..add('id', id) 46 | ..add('title', title) 47 | ..add('subtitle', subtitle) 48 | ..add('authors', authors) 49 | ..add('thumbnail', thumbnail) 50 | ..add('largeImage', largeImage) 51 | ..add('description', description) 52 | ..add('publishedDate', publishedDate)) 53 | .toString(); 54 | } 55 | -------------------------------------------------------------------------------- /lib/pages/fav_page/fav_books_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:search_book/domain/book.dart'; 5 | 6 | part 'fav_books_state.g.dart'; 7 | 8 | abstract class FavBooksState 9 | implements Built { 10 | bool get isLoading; 11 | 12 | BuiltList get books; 13 | 14 | FavBooksState._(); 15 | 16 | factory FavBooksState([updates(FavBooksStateBuilder b)]) = _$FavBooksState; 17 | 18 | factory FavBooksState.initial() { 19 | return FavBooksState((b) => b 20 | ..isLoading = true 21 | ..books = ListBuilder()); 22 | } 23 | } 24 | 25 | abstract class FavBookItem implements Built { 26 | bool get isLoading; 27 | 28 | String get id; 29 | 30 | @nullable 31 | String get title; 32 | 33 | @nullable 34 | String get subtitle; 35 | 36 | @nullable 37 | String get thumbnail; 38 | 39 | FavBookItem._(); 40 | 41 | factory FavBookItem([updates(FavBookItemBuilder b)]) = _$FavBookItem; 42 | 43 | Book toBookModel() { 44 | return Book( 45 | (b) => b 46 | ..id = id 47 | ..title = title 48 | ..subtitle = subtitle 49 | ..thumbnail = thumbnail, 50 | ); 51 | } 52 | } 53 | 54 | @immutable 55 | abstract class FavBookPartialChange {} 56 | 57 | class FavIdsListChange implements FavBookPartialChange { 58 | final List ids; 59 | 60 | const FavIdsListChange(this.ids); 61 | } 62 | 63 | class LoadedFavBookChange implements FavBookPartialChange { 64 | final Book book; 65 | 66 | const LoadedFavBookChange(this.book); 67 | } 68 | 69 | class ErrorFavBookChange implements FavBookPartialChange { 70 | final error; 71 | 72 | const ErrorFavBookChange(this.error); 73 | } 74 | -------------------------------------------------------------------------------- /.idea/libraries/Dart_SDK.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # search_book_bloc_pattern_rxdart :iphone: 2 | 3 | Search book using goole book api :clap: :clap: :clap: 4 | Using **`BLoC pattern`** with **[`rxdart`](https://github.com/ReactiveX/rxdart)** library :muscle:
5 | Give it a star :star: :) if it is helpful :thumbsup:. Thanks 6 | 7 | ## Trying 8 | 9 | Download and install [👍 File apk 👍](./build/app/outputs/apk/release/app-release.apk) 10 | 11 | ## Demo :camera: :art: 12 | 13 | :video_camera: [Video demo](https://youtu.be/FH7LPKVSYyg) :tv: 14 |
15 | 16 | | Home page | Detail page | Favorites page | 17 | | ------------- | ------------- | -------------| 18 | | | | | 19 | | Home page | Detail page | Favorites page | 20 | | | | | 21 | 22 | ## Develop 👏 23 | 24 | Make sure finish [install Flutter](https://flutter.io/get-started/install/) successfully 25 | 26 | 1. Clone this repo by: `git clone https://github.com/hoc081098/Search-book-api-demo_BLoC_pattern_RxDart.git` 27 | 2. Install all the packages by: `flutter packages get` 28 | 3. Run command `flutter packages pub run build_runner build` to generate build_value classes (optional because i pushed files *.g.dart) 29 | 4. Run app on your simulator or device by: `flutter run` 30 | 31 | ## Getting Started :fire: :fire: :fire: :fire: :fire: :fire: :fire: :fire: 32 | 33 | For help getting started with Flutter, view our online 34 | [documentation](https://flutter.io/). 35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/data/favorited_books_repo_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:built_collection/built_collection.dart'; 4 | import 'package:distinct_value_connectable_stream/distinct_value_connectable_stream.dart'; 5 | import 'package:meta/meta.dart'; 6 | import 'package:rx_shared_preferences/rx_shared_preferences.dart'; 7 | import 'package:rxdart/rxdart.dart'; 8 | import 'package:search_book/domain/toggle_fav_result.dart'; 9 | import 'package:search_book/domain/favorited_books_repo.dart'; 10 | 11 | class FavoritedBooksRepoImpl implements FavoritedBooksRepo { 12 | @visibleForTesting 13 | static const favoritedIdsKey = 14 | 'com.hoc.search_book_api_search_book_rxdart.favorited_ids'; 15 | 16 | final RxSharedPreferences _rxPrefs; 17 | 18 | @override 19 | final ValueStream> favoritedIds$; 20 | 21 | FavoritedBooksRepoImpl(this._rxPrefs) 22 | : favoritedIds$ = _rxPrefs 23 | .getStringListStream(favoritedIdsKey) 24 | .map((ids) => BuiltSet.of(ids ?? [])) 25 | .publishValueDistinct() 26 | ..listen((ids) => print('[FAV_IDS] ids=$ids')) 27 | ..connect(); 28 | 29 | @override 30 | Future toggleFavorited(String bookId) async { 31 | final ids = (await _rxPrefs.getStringList(favoritedIdsKey)) ?? []; 32 | 33 | bool added; 34 | List newIds; 35 | if (ids.contains(bookId)) { 36 | newIds = ids.where((id) => id != bookId).toList(); 37 | added = false; 38 | } else { 39 | newIds = [...ids, bookId]; 40 | added = true; 41 | } 42 | 43 | try { 44 | final bool result = await _rxPrefs.setStringList(favoritedIdsKey, newIds); 45 | return ToggleFavResult( 46 | (b) => b 47 | ..added = added 48 | ..error = null 49 | ..result = result 50 | ..id = bookId, 51 | ); 52 | } catch (e) { 53 | return ToggleFavResult( 54 | (b) => b 55 | ..added = added 56 | ..error = e 57 | ..result = false 58 | ..id = bookId, 59 | ); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 9 | 10 | 15 | 19 | 26 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/data/book_repo_impl.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:search_book/data/api/book_api.dart'; 3 | import 'package:search_book/data/api/book_response.dart'; 4 | import 'package:search_book/data/mappers.dart'; 5 | import 'package:search_book/domain/book.dart'; 6 | import 'package:search_book/domain/book_repo.dart'; 7 | import 'package:search_book/domain/cache_policy.dart'; 8 | import 'package:tuple/tuple.dart'; 9 | 10 | class BookRepoImpl implements BookRepo { 11 | static const _timeoutInMilliseconds = 120000; // 2 minutes 12 | final Map> _cached = {}; 13 | 14 | /// 15 | final BookApi _api; 16 | final Mappers _mappers; 17 | 18 | BookRepoImpl(this._api, this._mappers); 19 | 20 | Stream _getBookByIdWithCached$( 21 | String id, 22 | CachePolicy cachePolicy, 23 | ) async* { 24 | /// 25 | /// 26 | /// 27 | final cachedBook = _cached[id]; 28 | 29 | if (cachedBook?.item2 != null) { 30 | yield cachedBook.item2; 31 | } 32 | 33 | /// 34 | /// 35 | /// 36 | 37 | if (cachePolicy == CachePolicy.networkOnly) { 38 | final book = await _api.getBookById(id); 39 | _cached[book.id] = Tuple2( 40 | DateTime.now().millisecondsSinceEpoch, 41 | book, 42 | ); 43 | yield book; 44 | return; 45 | } 46 | 47 | /// 48 | /// 49 | /// 50 | if (cachePolicy == CachePolicy.localFirst) { 51 | final shouldFetch = (cachedBook?.item2 == null || // not in cached 52 | DateTime.now().millisecondsSinceEpoch - cachedBook.item1 >= 53 | _timeoutInMilliseconds); // in cached but timeout 54 | 55 | if (shouldFetch) { 56 | final book = await _api.getBookById(id); 57 | _cached[book.id] = Tuple2(DateTime.now().millisecondsSinceEpoch, book); 58 | yield book; 59 | } 60 | return; 61 | } 62 | 63 | /// 64 | /// 65 | /// 66 | } 67 | 68 | @override 69 | Stream getBookBy({ 70 | String id, 71 | CachePolicy cachePolicy = CachePolicy.networkOnly, 72 | }) { 73 | assert(id != null); 74 | return _getBookByIdWithCached$(id, cachePolicy) 75 | .map(_mappers.bookResponseToDomain); 76 | } 77 | 78 | @override 79 | Future> searchBook({ 80 | String query, 81 | int startIndex = 0, 82 | }) async { 83 | assert(query != null); 84 | final booksResponse = await _api.searchBook( 85 | query: query, 86 | startIndex: startIndex, 87 | ); 88 | final book = booksResponse.map(_mappers.bookResponseToDomain); 89 | return BuiltList.of(book); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /test/shared_pref_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:rx_shared_preferences/rx_shared_preferences.dart'; 4 | import 'package:search_book/data/favorited_books_repo_impl.dart'; 5 | import 'package:search_book/domain/toggle_fav_result.dart'; 6 | import 'package:mockito/mockito.dart'; 7 | 8 | class MockRxPrefs extends Mock implements RxSharedPreferences {} 9 | 10 | main() { 11 | WidgetsFlutterBinding.ensureInitialized(); 12 | 13 | group('Test $FavoritedBooksRepoImpl', () { 14 | const kTestValues = { 15 | 'flutter.${FavoritedBooksRepoImpl.favoritedIdsKey}': [], 16 | }; 17 | FavoritedBooksRepoImpl favBooksRepo; 18 | MockRxPrefs mockRxPrefs; 19 | 20 | setUp(() async { 21 | mockRxPrefs = MockRxPrefs(); 22 | 23 | when(mockRxPrefs 24 | .getStringListStream(FavoritedBooksRepoImpl.favoritedIdsKey)) 25 | .thenAnswer((_) => Stream.value([])); 26 | when(mockRxPrefs.getStringList(FavoritedBooksRepoImpl.favoritedIdsKey)) 27 | .thenAnswer((_) => Future.value([])); 28 | when(mockRxPrefs.setStringList( 29 | FavoritedBooksRepoImpl.favoritedIdsKey, any)) 30 | .thenAnswer((_) => Future.value(true)); 31 | 32 | favBooksRepo = FavoritedBooksRepoImpl(mockRxPrefs); 33 | }); 34 | 35 | tearDown(() async {}); 36 | 37 | test('Emit initial value', () async { 38 | await expectLater(favBooksRepo.favoritedIds$, emits([])); 39 | }); 40 | 41 | test('Add or remove id', () async { 42 | when(mockRxPrefs.getStringList(FavoritedBooksRepoImpl.favoritedIdsKey)) 43 | .thenAnswer((_) => Future.value([])); 44 | 45 | const id = 'hoc081098'; 46 | final result1 = ToggleFavResult( 47 | (b) => b 48 | ..id = id 49 | ..added = true 50 | ..error = null 51 | ..result = true, 52 | ); 53 | expect(await favBooksRepo.toggleFavorited(id), result1); 54 | 55 | when(mockRxPrefs.getStringList(FavoritedBooksRepoImpl.favoritedIdsKey)) 56 | .thenAnswer((_) => Future.value([id])); 57 | 58 | final result2 = ToggleFavResult( 59 | (b) => b 60 | ..id = id 61 | ..added = false 62 | ..error = null 63 | ..result = true, 64 | ); 65 | expect(await favBooksRepo.toggleFavorited(id), result2); 66 | }); 67 | 68 | test('Stream emit value after add or remove id', () async { 69 | 70 | 71 | const id = 'hoc081098'; 72 | const expected = >[ 73 | [], 74 | [id], 75 | [], 76 | [id], 77 | ]; 78 | 79 | mockRxPrefs = MockRxPrefs(); 80 | when(mockRxPrefs 81 | .getStringListStream(FavoritedBooksRepoImpl.favoritedIdsKey)) 82 | .thenAnswer((_) => Stream>.fromIterable(expected)); 83 | favBooksRepo = FavoritedBooksRepoImpl(mockRxPrefs); 84 | 85 | await expectLater( 86 | favBooksRepo.favoritedIds$, 87 | emitsInOrder(expected), 88 | ); 89 | }); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/pages/detail_page/detail_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:built_collection/built_collection.dart'; 4 | import 'package:disposebag/disposebag.dart'; 5 | import 'package:distinct_value_connectable_stream/distinct_value_connectable_stream.dart'; 6 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 7 | import 'package:rxdart/rxdart.dart'; 8 | import 'package:search_book/domain/book.dart'; 9 | import 'package:search_book/domain/book_repo.dart'; 10 | import 'package:search_book/pages/detail_page/detail_state.dart'; 11 | import 'package:search_book/domain/favorited_books_repo.dart'; 12 | 13 | // ignore_for_file: close_sinks 14 | 15 | class DetailBloc implements BaseBloc { 16 | /// Outputs 17 | final ValueStream bookDetail$; 18 | final Stream error$; 19 | 20 | /// Inputs 21 | final Future Function() refresh; 22 | final void Function() toggleFavorited; 23 | 24 | /// Clean up resource 25 | final void Function() _dispose; 26 | 27 | DetailBloc._( 28 | this.bookDetail$, 29 | this.error$, 30 | this.refresh, 31 | this.toggleFavorited, 32 | this._dispose, 33 | ); 34 | 35 | factory DetailBloc( 36 | final BookRepo bookRepo, 37 | final FavoritedBooksRepo favBooksRepo, 38 | final Book initial, 39 | ) { 40 | assert(bookRepo != null); 41 | assert(favBooksRepo != null); 42 | assert(initial != null); 43 | 44 | /// Controllers 45 | final refreshController = PublishSubject(); 46 | final errorController = PublishSubject(); 47 | final toggleController = PublishSubject(); 48 | 49 | final book$ = refreshController.exhaustMap( 50 | (completer) async* { 51 | try { 52 | yield* bookRepo.getBookBy(id: initial.id); 53 | } catch (e) { 54 | errorController.add(e); 55 | } finally { 56 | completer.complete(); 57 | } 58 | }, 59 | ).startWith(initial); 60 | 61 | final state$ = Rx.combineLatest2( 62 | book$, 63 | favBooksRepo.favoritedIds$, 64 | (Book book, BuiltSet ids) { 65 | return BookDetailState.fromDomain( 66 | book, 67 | ids.contains(book.id), 68 | ); 69 | }, 70 | ).publishValueSeededDistinct( 71 | seedValue: BookDetailState.fromDomain(initial)); 72 | 73 | /// DisposeBag 74 | final bag = DisposeBag( 75 | [ 76 | toggleController 77 | .throttleTime(const Duration(milliseconds: 600)) 78 | .listen((_) => favBooksRepo.toggleFavorited(initial.id)), 79 | state$.listen((book) => print('[DETAIL] book=$book')), 80 | // 81 | state$.connect(), 82 | // 83 | refreshController, 84 | errorController, 85 | toggleController, 86 | ], 87 | ); 88 | 89 | print('[DETAIL] new id=${initial.id}'); 90 | 91 | return DetailBloc._( 92 | state$, 93 | errorController, 94 | () { 95 | final completer = Completer(); 96 | refreshController.add(completer); 97 | return completer.future; 98 | }, 99 | () => toggleController.add(null), 100 | () => 101 | bag.dispose().then((_) => print('[DETAIL] dispose id=${initial.id}')), 102 | ); 103 | } 104 | 105 | @override 106 | void dispose() => _dispose(); 107 | } 108 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def parse_KV_file(file, separator='=') 14 | file_abs_path = File.expand_path(file) 15 | if !File.exists? file_abs_path 16 | return []; 17 | end 18 | generated_key_values = {} 19 | skip_line_start_symbols = ["#", "/"] 20 | File.foreach(file_abs_path) do |line| 21 | next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } 22 | plugin = line.split(pattern=separator) 23 | if plugin.length == 2 24 | podname = plugin[0].strip() 25 | path = plugin[1].strip() 26 | podpath = File.expand_path("#{path}", file_abs_path) 27 | generated_key_values[podname] = podpath 28 | else 29 | puts "Invalid plugin specification: #{line}" 30 | end 31 | end 32 | generated_key_values 33 | end 34 | 35 | target 'Runner' do 36 | # Flutter Pod 37 | 38 | copied_flutter_dir = File.join(__dir__, 'Flutter') 39 | copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') 40 | copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') 41 | unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) 42 | # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. 43 | # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. 44 | # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. 45 | 46 | generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') 47 | unless File.exist?(generated_xcode_build_settings_path) 48 | raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" 49 | end 50 | generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) 51 | cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; 52 | 53 | unless File.exist?(copied_framework_path) 54 | FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) 55 | end 56 | unless File.exist?(copied_podspec_path) 57 | FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) 58 | end 59 | end 60 | 61 | # Keep pod path relative so it can be checked into Podfile.lock. 62 | pod 'Flutter', :path => 'Flutter' 63 | 64 | # Plugin Pods 65 | 66 | # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock 67 | # referring to absolute paths on developers' machines. 68 | system('rm -rf .symlinks') 69 | system('mkdir -p .symlinks/plugins') 70 | plugin_pods = parse_KV_file('../.flutter-plugins') 71 | plugin_pods.each do |name, path| 72 | symlink = File.join('.symlinks', 'plugins', name) 73 | File.symlink(path, symlink) 74 | pod name, :path => File.join(symlink, 'ios') 75 | end 76 | end 77 | 78 | # Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. 79 | install! 'cocoapods', :disable_input_output_paths => true 80 | 81 | post_install do |installer| 82 | installer.pods_project.targets.each do |target| 83 | target.build_configurations.each do |config| 84 | config.build_settings['ENABLE_BITCODE'] = 'NO' 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /lib/domain/toggle_fav_result.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'toggle_fav_result.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$ToggleFavResult extends ToggleFavResult { 10 | @override 11 | final String id; 12 | @override 13 | final bool added; 14 | @override 15 | final bool result; 16 | @override 17 | final Object error; 18 | 19 | factory _$ToggleFavResult([void Function(ToggleFavResultBuilder) updates]) => 20 | (new ToggleFavResultBuilder()..update(updates)).build(); 21 | 22 | _$ToggleFavResult._({this.id, this.added, this.result, this.error}) 23 | : super._() { 24 | if (id == null) { 25 | throw new BuiltValueNullFieldError('ToggleFavResult', 'id'); 26 | } 27 | if (added == null) { 28 | throw new BuiltValueNullFieldError('ToggleFavResult', 'added'); 29 | } 30 | } 31 | 32 | @override 33 | ToggleFavResult rebuild(void Function(ToggleFavResultBuilder) updates) => 34 | (toBuilder()..update(updates)).build(); 35 | 36 | @override 37 | ToggleFavResultBuilder toBuilder() => 38 | new ToggleFavResultBuilder()..replace(this); 39 | 40 | @override 41 | bool operator ==(Object other) { 42 | if (identical(other, this)) return true; 43 | return other is ToggleFavResult && 44 | id == other.id && 45 | added == other.added && 46 | result == other.result && 47 | error == other.error; 48 | } 49 | 50 | @override 51 | int get hashCode { 52 | return $jf($jc( 53 | $jc($jc($jc(0, id.hashCode), added.hashCode), result.hashCode), 54 | error.hashCode)); 55 | } 56 | 57 | @override 58 | String toString() { 59 | return (newBuiltValueToStringHelper('ToggleFavResult') 60 | ..add('id', id) 61 | ..add('added', added) 62 | ..add('result', result) 63 | ..add('error', error)) 64 | .toString(); 65 | } 66 | } 67 | 68 | class ToggleFavResultBuilder 69 | implements Builder { 70 | _$ToggleFavResult _$v; 71 | 72 | String _id; 73 | String get id => _$this._id; 74 | set id(String id) => _$this._id = id; 75 | 76 | bool _added; 77 | bool get added => _$this._added; 78 | set added(bool added) => _$this._added = added; 79 | 80 | bool _result; 81 | bool get result => _$this._result; 82 | set result(bool result) => _$this._result = result; 83 | 84 | Object _error; 85 | Object get error => _$this._error; 86 | set error(Object error) => _$this._error = error; 87 | 88 | ToggleFavResultBuilder(); 89 | 90 | ToggleFavResultBuilder get _$this { 91 | if (_$v != null) { 92 | _id = _$v.id; 93 | _added = _$v.added; 94 | _result = _$v.result; 95 | _error = _$v.error; 96 | _$v = null; 97 | } 98 | return this; 99 | } 100 | 101 | @override 102 | void replace(ToggleFavResult other) { 103 | if (other == null) { 104 | throw new ArgumentError.notNull('other'); 105 | } 106 | _$v = other as _$ToggleFavResult; 107 | } 108 | 109 | @override 110 | void update(void Function(ToggleFavResultBuilder) updates) { 111 | if (updates != null) updates(this); 112 | } 113 | 114 | @override 115 | _$ToggleFavResult build() { 116 | final _$result = _$v ?? 117 | new _$ToggleFavResult._( 118 | id: id, added: added, result: result, error: error); 119 | replace(_$result); 120 | return _$result; 121 | } 122 | } 123 | 124 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 125 | -------------------------------------------------------------------------------- /lib/generated/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | // ignore_for_file: non_constant_identifier_names 7 | // ignore_for_file: camel_case_types 8 | // ignore_for_file: prefer_single_quotes 9 | 10 | // This file is automatically generated. DO NOT EDIT, all your changes would be lost. 11 | class S implements WidgetsLocalizations { 12 | const S(); 13 | 14 | static S current; 15 | 16 | static const GeneratedLocalizationsDelegate delegate = 17 | GeneratedLocalizationsDelegate(); 18 | 19 | static S of(BuildContext context) => Localizations.of(context, S); 20 | 21 | @override 22 | TextDirection get textDirection => TextDirection.ltr; 23 | } 24 | 25 | class $en extends S { 26 | const $en(); 27 | } 28 | 29 | class GeneratedLocalizationsDelegate extends LocalizationsDelegate { 30 | const GeneratedLocalizationsDelegate(); 31 | 32 | List get supportedLocales { 33 | return const [ 34 | Locale("en", ""), 35 | ]; 36 | } 37 | 38 | LocaleListResolutionCallback listResolution( 39 | {Locale fallback, bool withCountry = true}) { 40 | return (List locales, Iterable supported) { 41 | if (locales == null || locales.isEmpty) { 42 | return fallback ?? supported.first; 43 | } else { 44 | return _resolve(locales.first, fallback, supported, withCountry); 45 | } 46 | }; 47 | } 48 | 49 | LocaleResolutionCallback resolution( 50 | {Locale fallback, bool withCountry = true}) { 51 | return (Locale locale, Iterable supported) { 52 | return _resolve(locale, fallback, supported, withCountry); 53 | }; 54 | } 55 | 56 | @override 57 | Future load(Locale locale) { 58 | final String lang = getLang(locale); 59 | if (lang != null) { 60 | switch (lang) { 61 | case "en": 62 | S.current = const $en(); 63 | return SynchronousFuture(S.current); 64 | default: 65 | // NO-OP. 66 | } 67 | } 68 | S.current = const S(); 69 | return SynchronousFuture(S.current); 70 | } 71 | 72 | @override 73 | bool isSupported(Locale locale) => _isSupported(locale, true); 74 | 75 | @override 76 | bool shouldReload(GeneratedLocalizationsDelegate old) => false; 77 | 78 | /// 79 | /// Internal method to resolve a locale from a list of locales. 80 | /// 81 | Locale _resolve(Locale locale, Locale fallback, Iterable supported, 82 | bool withCountry) { 83 | if (locale == null || !_isSupported(locale, withCountry)) { 84 | return fallback ?? supported.first; 85 | } 86 | 87 | final Locale languageLocale = Locale(locale.languageCode, ""); 88 | if (supported.contains(locale)) { 89 | return locale; 90 | } else if (supported.contains(languageLocale)) { 91 | return languageLocale; 92 | } else { 93 | final Locale fallbackLocale = fallback ?? supported.first; 94 | return fallbackLocale; 95 | } 96 | } 97 | 98 | /// 99 | /// Returns true if the specified locale is supported, false otherwise. 100 | /// 101 | bool _isSupported(Locale locale, bool withCountry) { 102 | if (locale != null) { 103 | for (Locale supportedLocale in supportedLocales) { 104 | // Language must always match both locales. 105 | if (supportedLocale.languageCode != locale.languageCode) { 106 | continue; 107 | } 108 | 109 | // If country code matches, return this locale. 110 | if (supportedLocale.countryCode == locale.countryCode) { 111 | return true; 112 | } 113 | 114 | // If no country requirement is requested, check if this locale has no country. 115 | if (true != withCountry && 116 | (supportedLocale.countryCode == null || 117 | supportedLocale.countryCode.isEmpty)) { 118 | return true; 119 | } 120 | } 121 | } 122 | return false; 123 | } 124 | } 125 | 126 | String getLang(Locale l) => l == null 127 | ? null 128 | : l.countryCode != null && l.countryCode.isEmpty 129 | ? l.languageCode 130 | : l.toString(); 131 | -------------------------------------------------------------------------------- /lib/pages/fav_page/fav_books_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:built_collection/built_collection.dart'; 4 | import 'package:disposebag/disposebag.dart'; 5 | import 'package:distinct_value_connectable_stream/distinct_value_connectable_stream.dart'; 6 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 7 | import 'package:rxdart/rxdart.dart'; 8 | import 'package:search_book/domain/book_repo.dart'; 9 | import 'package:search_book/domain/cache_policy.dart'; 10 | import 'package:search_book/pages/fav_page/fav_books_state.dart'; 11 | import 'package:search_book/domain/favorited_books_repo.dart'; 12 | import 'package:tuple/tuple.dart'; 13 | 14 | // ignore_for_file: close_sinks 15 | 16 | class FavBooksInteractor { 17 | final BookRepo _bookRepo; 18 | final ValueStream> favoritedIds$; 19 | final void Function(String) toggleFavorite; 20 | 21 | FavBooksInteractor(this._bookRepo, FavoritedBooksRepo favBooksRepo) 22 | : assert(_bookRepo != null), 23 | assert(favBooksRepo != null), 24 | favoritedIds$ = favBooksRepo.favoritedIds$, 25 | toggleFavorite = favBooksRepo.toggleFavorited; 26 | 27 | Stream partialChanges( 28 | Iterable ids, [ 29 | bool forceUpdate = false, 30 | Completer completer, 31 | ]) { 32 | return Stream.fromIterable(ids) 33 | .flatMap((id) { 34 | final stream = _bookRepo.getBookBy( 35 | id: id, 36 | cachePolicy: 37 | forceUpdate ? CachePolicy.networkOnly : CachePolicy.localFirst, 38 | ); 39 | return stream 40 | .map((book) => LoadedFavBookChange(book)) 41 | .onErrorReturnWith((e) => ErrorFavBookChange(e)); 42 | }) 43 | .startWith(FavIdsListChange(ids.toList(growable: false))) 44 | .doOnDone(() => completer?.complete()); 45 | } 46 | } 47 | 48 | class FavBooksBloc implements BaseBloc { 49 | /// Inputs 50 | final void Function(String) removeFavorite; 51 | final Future Function() refresh; 52 | 53 | /// Outputs 54 | final ValueStream state$; 55 | 56 | /// Clean up resources 57 | final void Function() _dispose; 58 | 59 | FavBooksBloc._( 60 | this.removeFavorite, 61 | this.refresh, 62 | this.state$, 63 | this._dispose, 64 | ); 65 | 66 | @override 67 | void dispose() => _dispose(); 68 | 69 | factory FavBooksBloc(final FavBooksInteractor interactor) { 70 | assert(interactor != null, 'interactor cannot be null'); 71 | 72 | /// Controllers 73 | final removeFavoriteController = PublishSubject(); 74 | final refreshController = PublishSubject>(); 75 | 76 | /// State stream 77 | final state$ = Rx.merge( 78 | [ 79 | interactor.favoritedIds$.switchMap(interactor.partialChanges), 80 | refreshController 81 | .withLatestFrom( 82 | interactor.favoritedIds$, 83 | (Completer completer, BuiltSet ids) => 84 | Tuple2(completer, ids), 85 | ) 86 | .exhaustMap((tuple2) => 87 | interactor.partialChanges(tuple2.item2, true, tuple2.item1)), 88 | ], 89 | ) 90 | .doOnData((change) => print('[FAV_BOOKS] change=$change')) 91 | .scan(_reducer, FavBooksState.initial()) 92 | .publishValueSeededDistinct(seedValue: FavBooksState.initial()); 93 | 94 | /// DisposeBag 95 | final bag = DisposeBag( 96 | [ 97 | refreshController, 98 | removeFavoriteController, 99 | // 100 | state$.listen((state) => print('[FAV_BOOKS] state=$state')), 101 | removeFavoriteController 102 | .throttleTime(const Duration(milliseconds: 600)) 103 | .listen(interactor.toggleFavorite), 104 | // 105 | state$.connect(), 106 | ], 107 | ); 108 | 109 | return FavBooksBloc._( 110 | removeFavoriteController.add, 111 | () { 112 | final completer = Completer(); 113 | refreshController.add(completer); 114 | return completer.future 115 | .whenComplete(() => print('[FAV_BOOKS] refresh done')); 116 | }, 117 | state$, 118 | () => bag.dispose().then((_) => print('[FAV_BOOKS] dispose')), 119 | ); 120 | } 121 | 122 | static FavBooksState _reducer( 123 | FavBooksState state, FavBookPartialChange change, _) { 124 | if (change is FavIdsListChange) { 125 | final books = ListBuilder( 126 | change.ids.map((id) { 127 | return FavBookItem((b) => b 128 | ..id = id 129 | ..isLoading = true); 130 | }), 131 | ); 132 | 133 | return state.rebuild((b) => b 134 | ..books = books 135 | ..isLoading = false); 136 | } 137 | if (change is LoadedFavBookChange) { 138 | final book = change.book; 139 | 140 | return state.rebuild((b) { 141 | b.books.map((bookItem) { 142 | if (bookItem.id == book.id) { 143 | return bookItem.rebuild((b) => b 144 | ..isLoading = false 145 | ..title = book.title 146 | ..subtitle = book.subtitle 147 | ..thumbnail = book.thumbnail); 148 | } 149 | return bookItem; 150 | }); 151 | }); 152 | } 153 | return state; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /android/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /lib/domain/book.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'book.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$Book extends Book { 10 | @override 11 | final String id; 12 | @override 13 | final String title; 14 | @override 15 | final String subtitle; 16 | @override 17 | final BuiltList authors; 18 | @override 19 | final String thumbnail; 20 | @override 21 | final String largeImage; 22 | @override 23 | final String description; 24 | @override 25 | final String publishedDate; 26 | 27 | factory _$Book([void Function(BookBuilder) updates]) => 28 | (new BookBuilder()..update(updates)).build(); 29 | 30 | _$Book._( 31 | {this.id, 32 | this.title, 33 | this.subtitle, 34 | this.authors, 35 | this.thumbnail, 36 | this.largeImage, 37 | this.description, 38 | this.publishedDate}) 39 | : super._() { 40 | if (id == null) { 41 | throw new BuiltValueNullFieldError('Book', 'id'); 42 | } 43 | } 44 | 45 | @override 46 | Book rebuild(void Function(BookBuilder) updates) => 47 | (toBuilder()..update(updates)).build(); 48 | 49 | @override 50 | BookBuilder toBuilder() => new BookBuilder()..replace(this); 51 | 52 | @override 53 | bool operator ==(Object other) { 54 | if (identical(other, this)) return true; 55 | return other is Book && 56 | id == other.id && 57 | title == other.title && 58 | subtitle == other.subtitle && 59 | authors == other.authors && 60 | thumbnail == other.thumbnail && 61 | largeImage == other.largeImage && 62 | description == other.description && 63 | publishedDate == other.publishedDate; 64 | } 65 | 66 | @override 67 | int get hashCode { 68 | return $jf($jc( 69 | $jc( 70 | $jc( 71 | $jc( 72 | $jc( 73 | $jc($jc($jc(0, id.hashCode), title.hashCode), 74 | subtitle.hashCode), 75 | authors.hashCode), 76 | thumbnail.hashCode), 77 | largeImage.hashCode), 78 | description.hashCode), 79 | publishedDate.hashCode)); 80 | } 81 | 82 | @override 83 | String toString() { 84 | return (newBuiltValueToStringHelper('Book') 85 | ..add('id', id) 86 | ..add('title', title) 87 | ..add('subtitle', subtitle) 88 | ..add('authors', authors) 89 | ..add('thumbnail', thumbnail) 90 | ..add('largeImage', largeImage) 91 | ..add('description', description) 92 | ..add('publishedDate', publishedDate)) 93 | .toString(); 94 | } 95 | } 96 | 97 | class BookBuilder implements Builder { 98 | _$Book _$v; 99 | 100 | String _id; 101 | String get id => _$this._id; 102 | set id(String id) => _$this._id = id; 103 | 104 | String _title; 105 | String get title => _$this._title; 106 | set title(String title) => _$this._title = title; 107 | 108 | String _subtitle; 109 | String get subtitle => _$this._subtitle; 110 | set subtitle(String subtitle) => _$this._subtitle = subtitle; 111 | 112 | ListBuilder _authors; 113 | ListBuilder get authors => 114 | _$this._authors ??= new ListBuilder(); 115 | set authors(ListBuilder authors) => _$this._authors = authors; 116 | 117 | String _thumbnail; 118 | String get thumbnail => _$this._thumbnail; 119 | set thumbnail(String thumbnail) => _$this._thumbnail = thumbnail; 120 | 121 | String _largeImage; 122 | String get largeImage => _$this._largeImage; 123 | set largeImage(String largeImage) => _$this._largeImage = largeImage; 124 | 125 | String _description; 126 | String get description => _$this._description; 127 | set description(String description) => _$this._description = description; 128 | 129 | String _publishedDate; 130 | String get publishedDate => _$this._publishedDate; 131 | set publishedDate(String publishedDate) => 132 | _$this._publishedDate = publishedDate; 133 | 134 | BookBuilder(); 135 | 136 | BookBuilder get _$this { 137 | if (_$v != null) { 138 | _id = _$v.id; 139 | _title = _$v.title; 140 | _subtitle = _$v.subtitle; 141 | _authors = _$v.authors?.toBuilder(); 142 | _thumbnail = _$v.thumbnail; 143 | _largeImage = _$v.largeImage; 144 | _description = _$v.description; 145 | _publishedDate = _$v.publishedDate; 146 | _$v = null; 147 | } 148 | return this; 149 | } 150 | 151 | @override 152 | void replace(Book other) { 153 | if (other == null) { 154 | throw new ArgumentError.notNull('other'); 155 | } 156 | _$v = other as _$Book; 157 | } 158 | 159 | @override 160 | void update(void Function(BookBuilder) updates) { 161 | if (updates != null) updates(this); 162 | } 163 | 164 | @override 165 | _$Book build() { 166 | _$Book _$result; 167 | try { 168 | _$result = _$v ?? 169 | new _$Book._( 170 | id: id, 171 | title: title, 172 | subtitle: subtitle, 173 | authors: _authors?.build(), 174 | thumbnail: thumbnail, 175 | largeImage: largeImage, 176 | description: description, 177 | publishedDate: publishedDate); 178 | } catch (_) { 179 | String _$failedField; 180 | try { 181 | _$failedField = 'authors'; 182 | _authors?.build(); 183 | } catch (e) { 184 | throw new BuiltValueNestedFieldError( 185 | 'Book', _$failedField, e.toString()); 186 | } 187 | rethrow; 188 | } 189 | replace(_$result); 190 | return _$result; 191 | } 192 | } 193 | 194 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 195 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 49 | 50 | 51 | 52 | 55 | 56 | 60 | 61 | 65 | 66 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 1577086221987 90 | 98 | 99 | 1577092859689 100 | 105 | 106 | 1577093594853 107 | 112 | 113 | 1577159040902 114 | 119 | 122 | 123 | 125 | 126 | 137 | 138 | 139 | 144 | -------------------------------------------------------------------------------- /lib/pages/detail_page/detail_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'detail_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$BookDetailState extends BookDetailState { 10 | @override 11 | final String id; 12 | @override 13 | final String title; 14 | @override 15 | final String subtitle; 16 | @override 17 | final BuiltList authors; 18 | @override 19 | final String largeImage; 20 | @override 21 | final String thumbnail; 22 | @override 23 | final bool isFavorited; 24 | @override 25 | final String description; 26 | @override 27 | final String publishedDate; 28 | 29 | factory _$BookDetailState([void Function(BookDetailStateBuilder) updates]) => 30 | (new BookDetailStateBuilder()..update(updates)).build(); 31 | 32 | _$BookDetailState._( 33 | {this.id, 34 | this.title, 35 | this.subtitle, 36 | this.authors, 37 | this.largeImage, 38 | this.thumbnail, 39 | this.isFavorited, 40 | this.description, 41 | this.publishedDate}) 42 | : super._() { 43 | if (id == null) { 44 | throw new BuiltValueNullFieldError('BookDetailState', 'id'); 45 | } 46 | } 47 | 48 | @override 49 | BookDetailState rebuild(void Function(BookDetailStateBuilder) updates) => 50 | (toBuilder()..update(updates)).build(); 51 | 52 | @override 53 | BookDetailStateBuilder toBuilder() => 54 | new BookDetailStateBuilder()..replace(this); 55 | 56 | @override 57 | bool operator ==(Object other) { 58 | if (identical(other, this)) return true; 59 | return other is BookDetailState && 60 | id == other.id && 61 | title == other.title && 62 | subtitle == other.subtitle && 63 | authors == other.authors && 64 | largeImage == other.largeImage && 65 | thumbnail == other.thumbnail && 66 | isFavorited == other.isFavorited && 67 | description == other.description && 68 | publishedDate == other.publishedDate; 69 | } 70 | 71 | @override 72 | int get hashCode { 73 | return $jf($jc( 74 | $jc( 75 | $jc( 76 | $jc( 77 | $jc( 78 | $jc( 79 | $jc($jc($jc(0, id.hashCode), title.hashCode), 80 | subtitle.hashCode), 81 | authors.hashCode), 82 | largeImage.hashCode), 83 | thumbnail.hashCode), 84 | isFavorited.hashCode), 85 | description.hashCode), 86 | publishedDate.hashCode)); 87 | } 88 | 89 | @override 90 | String toString() { 91 | return (newBuiltValueToStringHelper('BookDetailState') 92 | ..add('id', id) 93 | ..add('title', title) 94 | ..add('subtitle', subtitle) 95 | ..add('authors', authors) 96 | ..add('largeImage', largeImage) 97 | ..add('thumbnail', thumbnail) 98 | ..add('isFavorited', isFavorited) 99 | ..add('description', description) 100 | ..add('publishedDate', publishedDate)) 101 | .toString(); 102 | } 103 | } 104 | 105 | class BookDetailStateBuilder 106 | implements Builder { 107 | _$BookDetailState _$v; 108 | 109 | String _id; 110 | String get id => _$this._id; 111 | set id(String id) => _$this._id = id; 112 | 113 | String _title; 114 | String get title => _$this._title; 115 | set title(String title) => _$this._title = title; 116 | 117 | String _subtitle; 118 | String get subtitle => _$this._subtitle; 119 | set subtitle(String subtitle) => _$this._subtitle = subtitle; 120 | 121 | ListBuilder _authors; 122 | ListBuilder get authors => 123 | _$this._authors ??= new ListBuilder(); 124 | set authors(ListBuilder authors) => _$this._authors = authors; 125 | 126 | String _largeImage; 127 | String get largeImage => _$this._largeImage; 128 | set largeImage(String largeImage) => _$this._largeImage = largeImage; 129 | 130 | String _thumbnail; 131 | String get thumbnail => _$this._thumbnail; 132 | set thumbnail(String thumbnail) => _$this._thumbnail = thumbnail; 133 | 134 | bool _isFavorited; 135 | bool get isFavorited => _$this._isFavorited; 136 | set isFavorited(bool isFavorited) => _$this._isFavorited = isFavorited; 137 | 138 | String _description; 139 | String get description => _$this._description; 140 | set description(String description) => _$this._description = description; 141 | 142 | String _publishedDate; 143 | String get publishedDate => _$this._publishedDate; 144 | set publishedDate(String publishedDate) => 145 | _$this._publishedDate = publishedDate; 146 | 147 | BookDetailStateBuilder(); 148 | 149 | BookDetailStateBuilder get _$this { 150 | if (_$v != null) { 151 | _id = _$v.id; 152 | _title = _$v.title; 153 | _subtitle = _$v.subtitle; 154 | _authors = _$v.authors?.toBuilder(); 155 | _largeImage = _$v.largeImage; 156 | _thumbnail = _$v.thumbnail; 157 | _isFavorited = _$v.isFavorited; 158 | _description = _$v.description; 159 | _publishedDate = _$v.publishedDate; 160 | _$v = null; 161 | } 162 | return this; 163 | } 164 | 165 | @override 166 | void replace(BookDetailState other) { 167 | if (other == null) { 168 | throw new ArgumentError.notNull('other'); 169 | } 170 | _$v = other as _$BookDetailState; 171 | } 172 | 173 | @override 174 | void update(void Function(BookDetailStateBuilder) updates) { 175 | if (updates != null) updates(this); 176 | } 177 | 178 | @override 179 | _$BookDetailState build() { 180 | _$BookDetailState _$result; 181 | try { 182 | _$result = _$v ?? 183 | new _$BookDetailState._( 184 | id: id, 185 | title: title, 186 | subtitle: subtitle, 187 | authors: _authors?.build(), 188 | largeImage: largeImage, 189 | thumbnail: thumbnail, 190 | isFavorited: isFavorited, 191 | description: description, 192 | publishedDate: publishedDate); 193 | } catch (_) { 194 | String _$failedField; 195 | try { 196 | _$failedField = 'authors'; 197 | _authors?.build(); 198 | } catch (e) { 199 | throw new BuiltValueNestedFieldError( 200 | 'BookDetailState', _$failedField, e.toString()); 201 | } 202 | rethrow; 203 | } 204 | replace(_$result); 205 | return _$result; 206 | } 207 | } 208 | 209 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 210 | -------------------------------------------------------------------------------- /lib/pages/fav_page/fav_books_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'fav_books_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$FavBooksState extends FavBooksState { 10 | @override 11 | final bool isLoading; 12 | @override 13 | final BuiltList books; 14 | 15 | factory _$FavBooksState([void Function(FavBooksStateBuilder) updates]) => 16 | (new FavBooksStateBuilder()..update(updates)).build(); 17 | 18 | _$FavBooksState._({this.isLoading, this.books}) : super._() { 19 | if (isLoading == null) { 20 | throw new BuiltValueNullFieldError('FavBooksState', 'isLoading'); 21 | } 22 | if (books == null) { 23 | throw new BuiltValueNullFieldError('FavBooksState', 'books'); 24 | } 25 | } 26 | 27 | @override 28 | FavBooksState rebuild(void Function(FavBooksStateBuilder) updates) => 29 | (toBuilder()..update(updates)).build(); 30 | 31 | @override 32 | FavBooksStateBuilder toBuilder() => new FavBooksStateBuilder()..replace(this); 33 | 34 | @override 35 | bool operator ==(Object other) { 36 | if (identical(other, this)) return true; 37 | return other is FavBooksState && 38 | isLoading == other.isLoading && 39 | books == other.books; 40 | } 41 | 42 | @override 43 | int get hashCode { 44 | return $jf($jc($jc(0, isLoading.hashCode), books.hashCode)); 45 | } 46 | 47 | @override 48 | String toString() { 49 | return (newBuiltValueToStringHelper('FavBooksState') 50 | ..add('isLoading', isLoading) 51 | ..add('books', books)) 52 | .toString(); 53 | } 54 | } 55 | 56 | class FavBooksStateBuilder 57 | implements Builder { 58 | _$FavBooksState _$v; 59 | 60 | bool _isLoading; 61 | bool get isLoading => _$this._isLoading; 62 | set isLoading(bool isLoading) => _$this._isLoading = isLoading; 63 | 64 | ListBuilder _books; 65 | ListBuilder get books => 66 | _$this._books ??= new ListBuilder(); 67 | set books(ListBuilder books) => _$this._books = books; 68 | 69 | FavBooksStateBuilder(); 70 | 71 | FavBooksStateBuilder get _$this { 72 | if (_$v != null) { 73 | _isLoading = _$v.isLoading; 74 | _books = _$v.books?.toBuilder(); 75 | _$v = null; 76 | } 77 | return this; 78 | } 79 | 80 | @override 81 | void replace(FavBooksState other) { 82 | if (other == null) { 83 | throw new ArgumentError.notNull('other'); 84 | } 85 | _$v = other as _$FavBooksState; 86 | } 87 | 88 | @override 89 | void update(void Function(FavBooksStateBuilder) updates) { 90 | if (updates != null) updates(this); 91 | } 92 | 93 | @override 94 | _$FavBooksState build() { 95 | _$FavBooksState _$result; 96 | try { 97 | _$result = _$v ?? 98 | new _$FavBooksState._(isLoading: isLoading, books: books.build()); 99 | } catch (_) { 100 | String _$failedField; 101 | try { 102 | _$failedField = 'books'; 103 | books.build(); 104 | } catch (e) { 105 | throw new BuiltValueNestedFieldError( 106 | 'FavBooksState', _$failedField, e.toString()); 107 | } 108 | rethrow; 109 | } 110 | replace(_$result); 111 | return _$result; 112 | } 113 | } 114 | 115 | class _$FavBookItem extends FavBookItem { 116 | @override 117 | final bool isLoading; 118 | @override 119 | final String id; 120 | @override 121 | final String title; 122 | @override 123 | final String subtitle; 124 | @override 125 | final String thumbnail; 126 | 127 | factory _$FavBookItem([void Function(FavBookItemBuilder) updates]) => 128 | (new FavBookItemBuilder()..update(updates)).build(); 129 | 130 | _$FavBookItem._( 131 | {this.isLoading, this.id, this.title, this.subtitle, this.thumbnail}) 132 | : super._() { 133 | if (isLoading == null) { 134 | throw new BuiltValueNullFieldError('FavBookItem', 'isLoading'); 135 | } 136 | if (id == null) { 137 | throw new BuiltValueNullFieldError('FavBookItem', 'id'); 138 | } 139 | } 140 | 141 | @override 142 | FavBookItem rebuild(void Function(FavBookItemBuilder) updates) => 143 | (toBuilder()..update(updates)).build(); 144 | 145 | @override 146 | FavBookItemBuilder toBuilder() => new FavBookItemBuilder()..replace(this); 147 | 148 | @override 149 | bool operator ==(Object other) { 150 | if (identical(other, this)) return true; 151 | return other is FavBookItem && 152 | isLoading == other.isLoading && 153 | id == other.id && 154 | title == other.title && 155 | subtitle == other.subtitle && 156 | thumbnail == other.thumbnail; 157 | } 158 | 159 | @override 160 | int get hashCode { 161 | return $jf($jc( 162 | $jc($jc($jc($jc(0, isLoading.hashCode), id.hashCode), title.hashCode), 163 | subtitle.hashCode), 164 | thumbnail.hashCode)); 165 | } 166 | 167 | @override 168 | String toString() { 169 | return (newBuiltValueToStringHelper('FavBookItem') 170 | ..add('isLoading', isLoading) 171 | ..add('id', id) 172 | ..add('title', title) 173 | ..add('subtitle', subtitle) 174 | ..add('thumbnail', thumbnail)) 175 | .toString(); 176 | } 177 | } 178 | 179 | class FavBookItemBuilder implements Builder { 180 | _$FavBookItem _$v; 181 | 182 | bool _isLoading; 183 | bool get isLoading => _$this._isLoading; 184 | set isLoading(bool isLoading) => _$this._isLoading = isLoading; 185 | 186 | String _id; 187 | String get id => _$this._id; 188 | set id(String id) => _$this._id = id; 189 | 190 | String _title; 191 | String get title => _$this._title; 192 | set title(String title) => _$this._title = title; 193 | 194 | String _subtitle; 195 | String get subtitle => _$this._subtitle; 196 | set subtitle(String subtitle) => _$this._subtitle = subtitle; 197 | 198 | String _thumbnail; 199 | String get thumbnail => _$this._thumbnail; 200 | set thumbnail(String thumbnail) => _$this._thumbnail = thumbnail; 201 | 202 | FavBookItemBuilder(); 203 | 204 | FavBookItemBuilder get _$this { 205 | if (_$v != null) { 206 | _isLoading = _$v.isLoading; 207 | _id = _$v.id; 208 | _title = _$v.title; 209 | _subtitle = _$v.subtitle; 210 | _thumbnail = _$v.thumbnail; 211 | _$v = null; 212 | } 213 | return this; 214 | } 215 | 216 | @override 217 | void replace(FavBookItem other) { 218 | if (other == null) { 219 | throw new ArgumentError.notNull('other'); 220 | } 221 | _$v = other as _$FavBookItem; 222 | } 223 | 224 | @override 225 | void update(void Function(FavBookItemBuilder) updates) { 226 | if (updates != null) updates(this); 227 | } 228 | 229 | @override 230 | _$FavBookItem build() { 231 | final _$result = _$v ?? 232 | new _$FavBookItem._( 233 | isLoading: isLoading, 234 | id: id, 235 | title: title, 236 | subtitle: subtitle, 237 | thumbnail: thumbnail); 238 | replace(_$result); 239 | return _$result; 240 | } 241 | } 242 | 243 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 244 | -------------------------------------------------------------------------------- /lib/pages/fav_page/fav_books_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:search_book/domain/book_repo.dart'; 3 | import 'package:search_book/widgets/fav_count_badge.dart'; 4 | import 'package:search_book/pages/detail_page/detail_bloc.dart'; 5 | import 'package:search_book/pages/detail_page/detail_page.dart'; 6 | import 'package:search_book/pages/fav_page/fav_books_bloc.dart'; 7 | import 'package:search_book/pages/fav_page/fav_books_state.dart'; 8 | import 'package:search_book/domain/favorited_books_repo.dart'; 9 | import 'package:flutter/material.dart'; 10 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 11 | import 'package:flutter_provider/flutter_provider.dart'; 12 | 13 | class FavoritedBooksPage extends StatelessWidget { 14 | @override 15 | Widget build(BuildContext context) { 16 | final bloc = BlocProvider.of(context); 17 | 18 | return StreamBuilder( 19 | stream: bloc.state$, 20 | initialData: bloc.state$.value, 21 | builder: (context, snapshot) { 22 | final state = snapshot.data; 23 | final favCount = state.books.length; 24 | 25 | final body = state.isLoading 26 | ? Container( 27 | constraints: BoxConstraints.expand(), 28 | decoration: BoxDecoration( 29 | gradient: LinearGradient( 30 | colors: [ 31 | Colors.teal.withOpacity(0.9), 32 | Colors.deepPurpleAccent.withOpacity(0.9), 33 | ], 34 | begin: AlignmentDirectional.topStart, 35 | end: AlignmentDirectional.bottomEnd, 36 | stops: [0.3, 0.7], 37 | ), 38 | ), 39 | child: Center( 40 | child: CircularProgressIndicator(), 41 | ), 42 | ) 43 | : FavBooksList(items: state.books); 44 | 45 | return Scaffold( 46 | appBar: AppBar( 47 | title: Row( 48 | children: [ 49 | Text('Favorited books'), 50 | SizedBox(width: 16), 51 | Hero( 52 | tag: 'FAV_COUNT', 53 | child: FavCountBadge( 54 | key: ValueKey(favCount), 55 | count: favCount, 56 | ), 57 | ), 58 | ], 59 | ), 60 | backgroundColor: Colors.teal, 61 | ), 62 | body: body, 63 | ); 64 | }, 65 | ); 66 | } 67 | } 68 | 69 | class FavBooksList extends StatelessWidget { 70 | final BuiltList items; 71 | 72 | const FavBooksList({Key key, @required this.items}) : super(key: key); 73 | 74 | @override 75 | Widget build(BuildContext context) { 76 | final bloc = BlocProvider.of(context); 77 | 78 | return RefreshIndicator( 79 | onRefresh: bloc.refresh, 80 | child: Container( 81 | decoration: BoxDecoration( 82 | gradient: LinearGradient( 83 | colors: [ 84 | Colors.teal.withOpacity(0.9), 85 | Colors.deepPurpleAccent.withOpacity(0.9), 86 | ], 87 | begin: AlignmentDirectional.topStart, 88 | end: AlignmentDirectional.bottomEnd, 89 | stops: [0.3, 0.7], 90 | ), 91 | ), 92 | constraints: BoxConstraints.expand(), 93 | child: ListView.builder( 94 | itemCount: items.length, 95 | physics: const AlwaysScrollableScrollPhysics(), 96 | itemBuilder: (context, index) => 97 | FavBookItemWidget(item: items[index]), 98 | ), 99 | ), 100 | ); 101 | } 102 | } 103 | 104 | class FavBookItemWidget extends StatelessWidget { 105 | final FavBookItem item; 106 | 107 | const FavBookItemWidget({Key key, this.item}) : super(key: key); 108 | 109 | @override 110 | Widget build(BuildContext context) { 111 | final bloc = BlocProvider.of(context); 112 | return Dismissible( 113 | child: InkWell( 114 | onTap: () { 115 | Navigator.push( 116 | context, 117 | MaterialPageRoute( 118 | builder: (BuildContext context) { 119 | return Consumer2( 120 | builder: (context, sharedPref, bookRepo) { 121 | return DetailPage( 122 | initBloc: () { 123 | return DetailBloc( 124 | bookRepo, 125 | sharedPref, 126 | item.toBookModel(), 127 | ); 128 | }, 129 | ); 130 | }, 131 | ); 132 | }, 133 | ), 134 | ); 135 | }, 136 | child: Container( 137 | constraints: BoxConstraints.expand(height: 224), 138 | margin: EdgeInsets.fromLTRB(16, 12, 16, 12), 139 | child: Stack( 140 | children: [ 141 | Align( 142 | alignment: Alignment.centerRight, 143 | child: Hero( 144 | tag: item.id, 145 | child: ClipRRect( 146 | borderRadius: BorderRadius.all(Radius.circular(8)), 147 | child: FadeInImage.assetNetwork( 148 | image: item.thumbnail ?? '', 149 | width: 64.0 * 1.8, 150 | height: 96.0 * 1.8, 151 | fit: BoxFit.cover, 152 | placeholder: 'assets/no_image.png', 153 | ), 154 | ), 155 | ), 156 | ), 157 | Align( 158 | alignment: Alignment.centerLeft, 159 | child: Container( 160 | margin: const EdgeInsets.only(right: 64.0 * 1.8 * 0.6), 161 | constraints: BoxConstraints.expand(), 162 | padding: EdgeInsets.all(16), 163 | decoration: BoxDecoration( 164 | borderRadius: BorderRadius.all(Radius.circular(8)), 165 | boxShadow: [ 166 | BoxShadow( 167 | color: Colors.black26, 168 | blurRadius: 14, 169 | ) 170 | ], 171 | ), 172 | child: Column( 173 | crossAxisAlignment: CrossAxisAlignment.stretch, 174 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 175 | children: [ 176 | Text( 177 | item.isLoading ? 'Loading...' : item.title, 178 | maxLines: 2, 179 | textAlign: TextAlign.start, 180 | overflow: TextOverflow.ellipsis, 181 | style: TextStyle( 182 | fontSize: 22, 183 | fontWeight: FontWeight.bold, 184 | ), 185 | ), 186 | Text( 187 | item.isLoading 188 | ? 'Loading...' 189 | : (item.subtitle == null || item.subtitle.isEmpty 190 | ? 'No subtitle...' 191 | : item.subtitle), 192 | maxLines: 2, 193 | textAlign: TextAlign.start, 194 | overflow: TextOverflow.ellipsis, 195 | style: TextStyle( 196 | color: Colors.grey, 197 | fontSize: 18, 198 | ), 199 | ), 200 | ], 201 | ), 202 | ), 203 | ) 204 | ], 205 | ), 206 | ), 207 | ), 208 | direction: DismissDirection.horizontal, 209 | onDismissed: (_) => bloc.removeFavorite(item.id), 210 | key: ValueKey(item.id), 211 | ); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /lib/pages/home_page/home_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:built_collection/built_collection.dart'; 4 | import 'package:disposebag/disposebag.dart'; 5 | import 'package:distinct_value_connectable_stream/distinct_value_connectable_stream.dart'; 6 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 7 | import 'package:rxdart/rxdart.dart'; 8 | import 'package:search_book/domain/book.dart'; 9 | import 'package:search_book/domain/book_repo.dart'; 10 | import 'package:search_book/pages/home_page/home_state.dart'; 11 | import 'package:tuple/tuple.dart'; 12 | import 'package:search_book/domain/favorited_books_repo.dart'; 13 | 14 | // ignore_for_file: close_sinks 15 | 16 | /// Home Bloc 17 | class HomeBloc implements BaseBloc { 18 | /// Input [Function]s 19 | final void Function(String) changeQuery; 20 | final void Function() loadNextPage; 21 | final void Function() retryNextPage; 22 | final void Function() retryFirstPage; 23 | final void Function(String) toggleFavorited; 24 | 25 | /// Ouput [Stream]s 26 | final ValueStream state$; 27 | final ValueStream favoriteCount$; 28 | 29 | /// Subscribe to this stream to show message like snackbar, toast, ... 30 | final Stream message$; 31 | 32 | /// Clean up resouce 33 | final void Function() _dispose; 34 | 35 | HomeBloc._( 36 | this.changeQuery, 37 | this.loadNextPage, 38 | this.state$, 39 | this._dispose, 40 | this.retryNextPage, 41 | this.retryFirstPage, 42 | this.toggleFavorited, 43 | this.message$, 44 | this.favoriteCount$, 45 | ); 46 | 47 | @override 48 | void dispose() => _dispose(); 49 | 50 | factory HomeBloc( 51 | final BookRepo bookRepo, 52 | final FavoritedBooksRepo favBooksRepo, 53 | ) { 54 | assert(bookRepo != null); 55 | assert(favBooksRepo != null); 56 | 57 | /// Stream controllers, receive input intents 58 | final queryController = PublishSubject(); 59 | final loadNextPageController = PublishSubject(); 60 | final retryNextPageController = PublishSubject(); 61 | final retryFirstPageController = PublishSubject(); 62 | final toggleFavoritedController = PublishSubject(); 63 | final controllers = [ 64 | queryController, 65 | loadNextPageController, 66 | retryNextPageController, 67 | retryFirstPageController, 68 | toggleFavoritedController, 69 | ]; 70 | 71 | /// Debounce query stream 72 | final searchString$ = queryController 73 | .debounceTime(const Duration(milliseconds: 500)) 74 | .distinct() 75 | .map((s) => s.trim()); 76 | 77 | /// Search intent 78 | final searchIntent$ = searchString$.mergeWith([ 79 | retryFirstPageController.withLatestFrom( 80 | searchString$, 81 | (_, String query) => query, 82 | ) 83 | ]).map((s) => HomeIntent.searchIntent(search: s)); 84 | 85 | /// Forward declare to [loadNextPageIntent] can access latest state via [DistinctValueConnectableStream.value] getter 86 | DistinctValueConnectableStream state$; 87 | 88 | /// Load next page intent 89 | final loadAndRetryNextPageIntent$ = Rx.merge( 90 | [ 91 | loadNextPageController.map((_) => state$.value).where((currentState) { 92 | /// Can load next page? 93 | return currentState.books.isNotEmpty && 94 | currentState.loadFirstPageError == null && 95 | currentState.loadNextPageError == null; 96 | }), 97 | retryNextPageController.map((_) => state$.value).where((currentState) { 98 | /// Can retry? 99 | return currentState.loadFirstPageError != null || 100 | currentState.loadNextPageError != null; 101 | }) 102 | ], 103 | ) 104 | .withLatestFrom( 105 | searchString$, 106 | (currentState, String query) => 107 | Tuple2(currentState.books.length, query), 108 | ) 109 | .map( 110 | (tuple2) => HomeIntent.loadNextPageIntent( 111 | search: tuple2.item2, 112 | startIndex: tuple2.item1, 113 | ), 114 | ); 115 | 116 | /// State stream 117 | state$ = Rx.combineLatest2( 118 | Rx.merge([searchIntent$, loadAndRetryNextPageIntent$]) // All intent 119 | .doOnData((intent) => print('[INTENT] $intent')) 120 | .switchMap((intent) => _processIntent$(intent, bookRepo)) 121 | .doOnData((change) => print('[CHANGE] $change')) 122 | .scan( 123 | (state, action, _) => action.reduce(state), 124 | HomePageState.initial(), 125 | ), 126 | favBooksRepo.favoritedIds$, 127 | (HomePageState state, BuiltSet ids) => state.rebuild( 128 | (b) => b.books.map( 129 | (book) => book.rebuild((b) => b.isFavorited = ids.contains(b.id)), 130 | ), 131 | ), 132 | ).publishValueSeededDistinct(seedValue: HomePageState.initial()); 133 | 134 | final message$ = 135 | _getMessage$(toggleFavoritedController, favBooksRepo, state$); 136 | 137 | final favoriteCount$ = favBooksRepo.favoritedIds$ 138 | .map((ids) => ids.length) 139 | .publishValueSeededDistinct(seedValue: 0); 140 | 141 | return HomeBloc._( 142 | queryController.add, 143 | () => loadNextPageController.add(null), 144 | state$, 145 | DisposeBag([ 146 | ...controllers, 147 | message$.listen((message) => print('[MESSAGE] $message')), 148 | favoriteCount$.listen((count) => print('[FAV_COUNT] $count')), 149 | state$.listen((state) => print('[STATE] $state')), 150 | state$.connect(), 151 | message$.connect(), 152 | favoriteCount$.connect(), 153 | ]).dispose, 154 | () => retryNextPageController.add(null), 155 | () => retryFirstPageController.add(null), 156 | toggleFavoritedController.add, 157 | message$, 158 | favoriteCount$, 159 | ); 160 | } 161 | } 162 | 163 | ConnectableStream _getMessage$( 164 | PublishSubject toggleFavoritedController, 165 | FavoritedBooksRepo favBooksRepo, 166 | DistinctValueConnectableStream state$, 167 | ) { 168 | return toggleFavoritedController 169 | .groupBy((id) => id) 170 | .map((group$) => group$.throttleTime(Duration(milliseconds: 600))) 171 | .flatMap((group$) => group$) 172 | .asyncExpand((id) => Stream.fromFuture(favBooksRepo.toggleFavorited(id))) 173 | .withLatestFrom( 174 | state$, 175 | (result, HomePageState item) => HomePageMessage.fromResult( 176 | result, 177 | item.books.firstWhere( 178 | (book) => book.id == result.id, 179 | orElse: () => null, 180 | ), 181 | ), 182 | ) 183 | .publish(); 184 | } 185 | 186 | /// Process [intent], convert [intent] to [Stream] of [PartialStateChange]s 187 | Stream _processIntent$( 188 | HomeIntent intent, 189 | BookRepo bookRepo, 190 | ) { 191 | perform( 192 | Stream streamFactory(), 193 | PARTIAL_CHANGE map(RESULT a), 194 | PARTIAL_CHANGE loading, 195 | PARTIAL_CHANGE onError(dynamic e), 196 | ) { 197 | return Rx.defer(streamFactory) 198 | .map(map) 199 | .startWith(loading) 200 | .doOnError((e, s) => print(s)) 201 | .onErrorReturnWith(onError); 202 | } 203 | 204 | searchIntentToPartialChange$(SearchIntent intent) => 205 | perform, PartialStateChange>( 206 | () { 207 | if (intent.search.isEmpty) { 208 | return Stream.value(BuiltList.of([])); 209 | } 210 | return Stream.fromFuture(bookRepo.searchBook(query: intent.search)); 211 | }, 212 | (list) { 213 | final bookItems = 214 | list.map((book) => BookItem.fromDomain(book)).toList(); 215 | return PartialStateChange.firstPageLoaded( 216 | books: bookItems, 217 | textQuery: intent.search, 218 | ); 219 | }, 220 | PartialStateChange.firstPageLoading(), 221 | (e) { 222 | return PartialStateChange.firstPageError( 223 | error: e, 224 | textQuery: intent.search, 225 | ); 226 | }, 227 | ); 228 | 229 | loadNextPageIntentToPartialChange$(LoadNextPageIntent intent) => 230 | perform, PartialStateChange>( 231 | () { 232 | return Stream.fromFuture( 233 | bookRepo.searchBook( 234 | query: intent.search, 235 | startIndex: intent.startIndex, 236 | ), 237 | ); 238 | }, 239 | (list) { 240 | final bookItems = 241 | list.map((book) => BookItem.fromDomain(book)).toList(); 242 | return PartialStateChange.nextPageLoaded( 243 | books: bookItems, 244 | textQuery: intent.search, 245 | ); 246 | }, 247 | PartialStateChange.nextPageLoading(), 248 | (e) { 249 | return PartialStateChange.nextPageError( 250 | error: e, 251 | textQuery: intent.search, 252 | ); 253 | }, 254 | ); 255 | 256 | return intent.join( 257 | searchIntentToPartialChange$, 258 | loadNextPageIntentToPartialChange$, 259 | ); 260 | } 261 | -------------------------------------------------------------------------------- /.idea/libraries/Dart_Packages.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /lib/pages/home_page/home_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'home_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$BookItem extends BookItem { 10 | @override 11 | final String id; 12 | @override 13 | final String title; 14 | @override 15 | final String subtitle; 16 | @override 17 | final String thumbnail; 18 | @override 19 | final bool isFavorited; 20 | 21 | factory _$BookItem([void Function(BookItemBuilder) updates]) => 22 | (new BookItemBuilder()..update(updates)).build(); 23 | 24 | _$BookItem._( 25 | {this.id, this.title, this.subtitle, this.thumbnail, this.isFavorited}) 26 | : super._() { 27 | if (id == null) { 28 | throw new BuiltValueNullFieldError('BookItem', 'id'); 29 | } 30 | } 31 | 32 | @override 33 | BookItem rebuild(void Function(BookItemBuilder) updates) => 34 | (toBuilder()..update(updates)).build(); 35 | 36 | @override 37 | BookItemBuilder toBuilder() => new BookItemBuilder()..replace(this); 38 | 39 | @override 40 | bool operator ==(Object other) { 41 | if (identical(other, this)) return true; 42 | return other is BookItem && 43 | id == other.id && 44 | title == other.title && 45 | subtitle == other.subtitle && 46 | thumbnail == other.thumbnail && 47 | isFavorited == other.isFavorited; 48 | } 49 | 50 | @override 51 | int get hashCode { 52 | return $jf($jc( 53 | $jc($jc($jc($jc(0, id.hashCode), title.hashCode), subtitle.hashCode), 54 | thumbnail.hashCode), 55 | isFavorited.hashCode)); 56 | } 57 | 58 | @override 59 | String toString() { 60 | return (newBuiltValueToStringHelper('BookItem') 61 | ..add('id', id) 62 | ..add('title', title) 63 | ..add('subtitle', subtitle) 64 | ..add('thumbnail', thumbnail) 65 | ..add('isFavorited', isFavorited)) 66 | .toString(); 67 | } 68 | } 69 | 70 | class BookItemBuilder implements Builder { 71 | _$BookItem _$v; 72 | 73 | String _id; 74 | String get id => _$this._id; 75 | set id(String id) => _$this._id = id; 76 | 77 | String _title; 78 | String get title => _$this._title; 79 | set title(String title) => _$this._title = title; 80 | 81 | String _subtitle; 82 | String get subtitle => _$this._subtitle; 83 | set subtitle(String subtitle) => _$this._subtitle = subtitle; 84 | 85 | String _thumbnail; 86 | String get thumbnail => _$this._thumbnail; 87 | set thumbnail(String thumbnail) => _$this._thumbnail = thumbnail; 88 | 89 | bool _isFavorited; 90 | bool get isFavorited => _$this._isFavorited; 91 | set isFavorited(bool isFavorited) => _$this._isFavorited = isFavorited; 92 | 93 | BookItemBuilder(); 94 | 95 | BookItemBuilder get _$this { 96 | if (_$v != null) { 97 | _id = _$v.id; 98 | _title = _$v.title; 99 | _subtitle = _$v.subtitle; 100 | _thumbnail = _$v.thumbnail; 101 | _isFavorited = _$v.isFavorited; 102 | _$v = null; 103 | } 104 | return this; 105 | } 106 | 107 | @override 108 | void replace(BookItem other) { 109 | if (other == null) { 110 | throw new ArgumentError.notNull('other'); 111 | } 112 | _$v = other as _$BookItem; 113 | } 114 | 115 | @override 116 | void update(void Function(BookItemBuilder) updates) { 117 | if (updates != null) updates(this); 118 | } 119 | 120 | @override 121 | _$BookItem build() { 122 | final _$result = _$v ?? 123 | new _$BookItem._( 124 | id: id, 125 | title: title, 126 | subtitle: subtitle, 127 | thumbnail: thumbnail, 128 | isFavorited: isFavorited); 129 | replace(_$result); 130 | return _$result; 131 | } 132 | } 133 | 134 | class _$HomePageState extends HomePageState { 135 | @override 136 | final String resultText; 137 | @override 138 | final BuiltList books; 139 | @override 140 | final bool isFirstPageLoading; 141 | @override 142 | final Object loadFirstPageError; 143 | @override 144 | final bool isNextPageLoading; 145 | @override 146 | final Object loadNextPageError; 147 | 148 | factory _$HomePageState([void Function(HomePageStateBuilder) updates]) => 149 | (new HomePageStateBuilder()..update(updates)).build(); 150 | 151 | _$HomePageState._( 152 | {this.resultText, 153 | this.books, 154 | this.isFirstPageLoading, 155 | this.loadFirstPageError, 156 | this.isNextPageLoading, 157 | this.loadNextPageError}) 158 | : super._() { 159 | if (resultText == null) { 160 | throw new BuiltValueNullFieldError('HomePageState', 'resultText'); 161 | } 162 | if (books == null) { 163 | throw new BuiltValueNullFieldError('HomePageState', 'books'); 164 | } 165 | if (isFirstPageLoading == null) { 166 | throw new BuiltValueNullFieldError('HomePageState', 'isFirstPageLoading'); 167 | } 168 | if (isNextPageLoading == null) { 169 | throw new BuiltValueNullFieldError('HomePageState', 'isNextPageLoading'); 170 | } 171 | } 172 | 173 | @override 174 | HomePageState rebuild(void Function(HomePageStateBuilder) updates) => 175 | (toBuilder()..update(updates)).build(); 176 | 177 | @override 178 | HomePageStateBuilder toBuilder() => new HomePageStateBuilder()..replace(this); 179 | 180 | @override 181 | bool operator ==(Object other) { 182 | if (identical(other, this)) return true; 183 | return other is HomePageState && 184 | resultText == other.resultText && 185 | books == other.books && 186 | isFirstPageLoading == other.isFirstPageLoading && 187 | loadFirstPageError == other.loadFirstPageError && 188 | isNextPageLoading == other.isNextPageLoading && 189 | loadNextPageError == other.loadNextPageError; 190 | } 191 | 192 | @override 193 | int get hashCode { 194 | return $jf($jc( 195 | $jc( 196 | $jc( 197 | $jc($jc($jc(0, resultText.hashCode), books.hashCode), 198 | isFirstPageLoading.hashCode), 199 | loadFirstPageError.hashCode), 200 | isNextPageLoading.hashCode), 201 | loadNextPageError.hashCode)); 202 | } 203 | 204 | @override 205 | String toString() { 206 | return (newBuiltValueToStringHelper('HomePageState') 207 | ..add('resultText', resultText) 208 | ..add('books', books) 209 | ..add('isFirstPageLoading', isFirstPageLoading) 210 | ..add('loadFirstPageError', loadFirstPageError) 211 | ..add('isNextPageLoading', isNextPageLoading) 212 | ..add('loadNextPageError', loadNextPageError)) 213 | .toString(); 214 | } 215 | } 216 | 217 | class HomePageStateBuilder 218 | implements Builder { 219 | _$HomePageState _$v; 220 | 221 | String _resultText; 222 | String get resultText => _$this._resultText; 223 | set resultText(String resultText) => _$this._resultText = resultText; 224 | 225 | ListBuilder _books; 226 | ListBuilder get books => 227 | _$this._books ??= new ListBuilder(); 228 | set books(ListBuilder books) => _$this._books = books; 229 | 230 | bool _isFirstPageLoading; 231 | bool get isFirstPageLoading => _$this._isFirstPageLoading; 232 | set isFirstPageLoading(bool isFirstPageLoading) => 233 | _$this._isFirstPageLoading = isFirstPageLoading; 234 | 235 | Object _loadFirstPageError; 236 | Object get loadFirstPageError => _$this._loadFirstPageError; 237 | set loadFirstPageError(Object loadFirstPageError) => 238 | _$this._loadFirstPageError = loadFirstPageError; 239 | 240 | bool _isNextPageLoading; 241 | bool get isNextPageLoading => _$this._isNextPageLoading; 242 | set isNextPageLoading(bool isNextPageLoading) => 243 | _$this._isNextPageLoading = isNextPageLoading; 244 | 245 | Object _loadNextPageError; 246 | Object get loadNextPageError => _$this._loadNextPageError; 247 | set loadNextPageError(Object loadNextPageError) => 248 | _$this._loadNextPageError = loadNextPageError; 249 | 250 | HomePageStateBuilder(); 251 | 252 | HomePageStateBuilder get _$this { 253 | if (_$v != null) { 254 | _resultText = _$v.resultText; 255 | _books = _$v.books?.toBuilder(); 256 | _isFirstPageLoading = _$v.isFirstPageLoading; 257 | _loadFirstPageError = _$v.loadFirstPageError; 258 | _isNextPageLoading = _$v.isNextPageLoading; 259 | _loadNextPageError = _$v.loadNextPageError; 260 | _$v = null; 261 | } 262 | return this; 263 | } 264 | 265 | @override 266 | void replace(HomePageState other) { 267 | if (other == null) { 268 | throw new ArgumentError.notNull('other'); 269 | } 270 | _$v = other as _$HomePageState; 271 | } 272 | 273 | @override 274 | void update(void Function(HomePageStateBuilder) updates) { 275 | if (updates != null) updates(this); 276 | } 277 | 278 | @override 279 | _$HomePageState build() { 280 | _$HomePageState _$result; 281 | try { 282 | _$result = _$v ?? 283 | new _$HomePageState._( 284 | resultText: resultText, 285 | books: books.build(), 286 | isFirstPageLoading: isFirstPageLoading, 287 | loadFirstPageError: loadFirstPageError, 288 | isNextPageLoading: isNextPageLoading, 289 | loadNextPageError: loadNextPageError); 290 | } catch (_) { 291 | String _$failedField; 292 | try { 293 | _$failedField = 'books'; 294 | books.build(); 295 | } catch (e) { 296 | throw new BuiltValueNestedFieldError( 297 | 'HomePageState', _$failedField, e.toString()); 298 | } 299 | rethrow; 300 | } 301 | replace(_$result); 302 | return _$result; 303 | } 304 | } 305 | 306 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 307 | -------------------------------------------------------------------------------- /lib/pages/detail_page/detail_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:search_book/pages/detail_page/detail_bloc.dart'; 2 | import 'package:search_book/pages/detail_page/detail_state.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_html/flutter_html.dart'; 5 | 6 | class DetailPage extends StatefulWidget { 7 | final DetailBloc Function() initBloc; 8 | 9 | const DetailPage({Key key, @required this.initBloc}) 10 | : assert(initBloc != null), 11 | super(key: key); 12 | 13 | _DetailPageState createState() => _DetailPageState(); 14 | } 15 | 16 | class _DetailPageState extends State { 17 | final _refreshIndicatorKey = GlobalKey(); 18 | DetailBloc _bloc; 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | _bloc = widget.initBloc(); 24 | WidgetsBinding.instance.addPostFrameCallback( 25 | (_) => _refreshIndicatorKey?.currentState?.show()); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return StreamBuilder( 31 | stream: _bloc.bookDetail$, 32 | initialData: _bloc.bookDetail$.value, 33 | builder: (context, snapshot) { 34 | final detail = snapshot.data; 35 | 36 | return Scaffold( 37 | body: Container( 38 | color: Color(0xFF736AB7), 39 | constraints: BoxConstraints.expand(), 40 | child: Stack( 41 | children: [ 42 | Container( 43 | child: detail.largeImage != null 44 | ? Image.network( 45 | detail.largeImage, 46 | fit: BoxFit.cover, 47 | height: 300.0, 48 | ) 49 | : Container(), 50 | constraints: BoxConstraints.expand(height: 300.0), 51 | ), 52 | Container( 53 | margin: EdgeInsets.only(top: 190.0), 54 | height: 110.0, 55 | decoration: BoxDecoration( 56 | gradient: LinearGradient( 57 | colors: [ 58 | Color(0x00736AB7), 59 | Color(0xFF736AB7), 60 | ], 61 | stops: [0.0, 0.9], 62 | begin: AlignmentDirectional.topStart, 63 | end: AlignmentDirectional.bottomStart, 64 | ), 65 | ), 66 | ), 67 | BookDetailContent( 68 | detail: detail, 69 | bloc: _bloc, 70 | refreshIndicatorKey: _refreshIndicatorKey, 71 | ), 72 | Container( 73 | decoration: BoxDecoration( 74 | color: Colors.black12, 75 | shape: BoxShape.circle, 76 | ), 77 | margin: EdgeInsets.only( 78 | top: MediaQuery.of(context).padding.top + 8, 79 | left: 8, 80 | ), 81 | child: BackButton(color: Colors.white), 82 | ) 83 | ], 84 | ), 85 | ), 86 | floatingActionButton: detail.isFavorited == null 87 | ? Container(width: 0, height: 0) 88 | : FloatingActionButton( 89 | onPressed: _bloc.toggleFavorited, 90 | child: Icon( 91 | detail.isFavorited ? Icons.star : Icons.star_border, 92 | ), 93 | ), 94 | floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, 95 | ); 96 | }, 97 | ); 98 | } 99 | 100 | @override 101 | void dispose() { 102 | _bloc.dispose(); 103 | super.dispose(); 104 | } 105 | } 106 | 107 | class BookDetailContent extends StatelessWidget { 108 | final BookDetailState detail; 109 | final DetailBloc bloc; 110 | final GlobalKey refreshIndicatorKey; 111 | 112 | const BookDetailContent({ 113 | Key key, 114 | @required this.detail, 115 | @required this.bloc, 116 | @required this.refreshIndicatorKey, 117 | }) : super(key: key); 118 | 119 | @override 120 | Widget build(BuildContext context) { 121 | print(detail.id); 122 | final headerStyle = TextStyle( 123 | color: Colors.white, 124 | fontFamily: 'Poppins', 125 | fontWeight: FontWeight.w500, 126 | fontSize: 18.0, 127 | ); 128 | final regularStyle = headerStyle.copyWith( 129 | fontSize: 14.0, 130 | color: Colors.teal.shade50, 131 | fontWeight: FontWeight.w400, 132 | ); 133 | 134 | return RefreshIndicator( 135 | key: refreshIndicatorKey, 136 | child: ListView( 137 | physics: const BouncingScrollPhysics(), 138 | children: [ 139 | Container( 140 | margin: EdgeInsets.only(left: 16, right: 16, top: 0.0), 141 | padding: const EdgeInsets.all(16), 142 | decoration: BoxDecoration( 143 | color: Color(0x8c333366), 144 | shape: BoxShape.rectangle, 145 | borderRadius: BorderRadius.circular(8.0), 146 | boxShadow: [ 147 | BoxShadow( 148 | color: Colors.black12, 149 | blurRadius: 10.0, 150 | offset: Offset( 151 | 0.0, 152 | 0.0, 153 | ), 154 | ) 155 | ], 156 | ), 157 | child: Column( 158 | crossAxisAlignment: CrossAxisAlignment.center, 159 | children: [ 160 | Container( 161 | decoration: BoxDecoration( 162 | boxShadow: [ 163 | BoxShadow( 164 | color: Colors.grey.shade300, 165 | blurRadius: 8, 166 | offset: Offset(4, 4), 167 | ), 168 | ], 169 | ), 170 | child: Hero( 171 | child: ClipRRect( 172 | borderRadius: BorderRadius.circular(4), 173 | clipBehavior: Clip.antiAlias, 174 | child: FadeInImage.assetNetwork( 175 | image: detail.thumbnail ?? '', 176 | width: 64.0 * 2, 177 | height: 96.0 * 2, 178 | fit: BoxFit.cover, 179 | placeholder: 'assets/no_image.png', 180 | ), 181 | ), 182 | tag: detail.id, 183 | ), 184 | ), 185 | SizedBox(height: 16), 186 | Text( 187 | detail.title ?? 'No title', 188 | maxLines: 2, 189 | textAlign: TextAlign.center, 190 | overflow: TextOverflow.ellipsis, 191 | style: headerStyle, 192 | ), 193 | SizedBox(height: 8), 194 | Text( 195 | detail.subtitle ?? 'No subtitle', 196 | maxLines: 2, 197 | overflow: TextOverflow.ellipsis, 198 | style: regularStyle, 199 | ), 200 | SizedBox(height: 12), 201 | Container( 202 | margin: EdgeInsets.symmetric(vertical: 8.0), 203 | height: 2.0, 204 | width: 128.0, 205 | color: Color(0xff00c6ff), 206 | ), 207 | SizedBox(height: 12), 208 | Row( 209 | mainAxisAlignment: MainAxisAlignment.center, 210 | children: [ 211 | Expanded( 212 | child: Row( 213 | children: [ 214 | Icon( 215 | Icons.edit, 216 | color: Theme.of(context).accentColor, 217 | ), 218 | SizedBox(width: 8.0), 219 | Expanded( 220 | child: Text( 221 | 'Authors: ${detail.authors?.join(', ') ?? 'No authors'}', 222 | style: regularStyle, 223 | maxLines: 5, 224 | overflow: TextOverflow.fade, 225 | textAlign: TextAlign.center, 226 | ), 227 | ), 228 | ], 229 | mainAxisAlignment: MainAxisAlignment.center, 230 | ), 231 | ), 232 | SizedBox(width: 4.0), 233 | Expanded( 234 | child: Row( 235 | children: [ 236 | Icon( 237 | Icons.date_range, 238 | color: Theme.of(context).accentColor, 239 | ), 240 | SizedBox(width: 8.0), 241 | Expanded( 242 | child: Text( 243 | 'Published date: ${detail.publishedDate ?? 'No published date'}', 244 | style: regularStyle, 245 | maxLines: 5, 246 | overflow: TextOverflow.fade, 247 | textAlign: TextAlign.center, 248 | ), 249 | ), 250 | ], 251 | mainAxisAlignment: MainAxisAlignment.center, 252 | ), 253 | ), 254 | ], 255 | ), 256 | ], 257 | ), 258 | ), 259 | Container( 260 | margin: EdgeInsets.symmetric(horizontal: 32.0), 261 | child: Column( 262 | crossAxisAlignment: CrossAxisAlignment.start, 263 | children: [ 264 | SizedBox(height: 32.0), 265 | Text('DESCRIPTION', style: headerStyle), 266 | Container( 267 | margin: EdgeInsets.symmetric(vertical: 8.0), 268 | height: 2.0, 269 | width: 32.0, 270 | color: Color(0xff00c6ff), 271 | ), 272 | Html( 273 | data: detail.description ?? 'No description', 274 | defaultTextStyle: regularStyle, 275 | ), 276 | ], 277 | ), 278 | ), 279 | Container(height: MediaQuery.of(context).size.height / 6), 280 | ], 281 | padding: EdgeInsets.fromLTRB(0.0, 72.0, 0.0, 32.0), 282 | ), 283 | onRefresh: bloc.refresh, 284 | ); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /lib/pages/home_page/home_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:sealed_unions/sealed_unions.dart'; 5 | import 'package:search_book/data/api/book_response.dart'; 6 | import 'package:search_book/domain/book.dart'; 7 | import 'package:search_book/domain/toggle_fav_result.dart'; 8 | 9 | part 'home_state.g.dart'; 10 | 11 | /// 12 | /// Represent from book item in list 13 | /// 14 | abstract class BookItem implements Built { 15 | String get id; 16 | 17 | @nullable 18 | String get title; 19 | 20 | @nullable 21 | String get subtitle; 22 | 23 | @nullable 24 | String get thumbnail; 25 | 26 | @nullable 27 | bool get isFavorited; 28 | 29 | BookItem._(); 30 | 31 | factory BookItem([updates(BookItemBuilder b)]) = _$BookItem; 32 | 33 | /// 34 | /// Create [BookItem] from [BookResponse] 35 | /// 36 | factory BookItem.fromDomain(Book book) { 37 | return BookItem( 38 | (b) => b 39 | ..id = book.id 40 | ..title = book.title 41 | ..subtitle = book.subtitle 42 | ..thumbnail = book.thumbnail, 43 | ); 44 | } 45 | 46 | Book toDomain() { 47 | return Book( 48 | (b) => b 49 | ..id = id 50 | ..title = title 51 | ..subtitle = subtitle 52 | ..thumbnail = thumbnail, 53 | ); 54 | } 55 | } 56 | 57 | String _toString(o) => o.toString(); 58 | 59 | /// 60 | /// Home page state 61 | /// 62 | abstract class HomePageState 63 | implements Built { 64 | String get resultText; 65 | 66 | BuiltList get books; 67 | 68 | bool get isFirstPageLoading; 69 | 70 | @nullable 71 | Object get loadFirstPageError; 72 | 73 | bool get isNextPageLoading; 74 | 75 | @nullable 76 | Object get loadNextPageError; 77 | 78 | HomePageState._(); 79 | 80 | factory HomePageState([updates(HomePageStateBuilder b)]) = _$HomePageState; 81 | 82 | factory HomePageState.initial() { 83 | return HomePageState((b) => b 84 | ..resultText = '' 85 | ..books = ListBuilder() 86 | ..isFirstPageLoading = false 87 | ..loadFirstPageError = null 88 | ..isNextPageLoading = false 89 | ..loadNextPageError = null); 90 | } 91 | } 92 | 93 | /// 94 | /// Home Page Partial Change 95 | /// 96 | class PartialStateChange extends Union6Impl< 97 | LoadingFirstPage, 98 | LoadFirstPageError, 99 | FirstPageLoaded, 100 | LoadingNextPage, 101 | NextPageLoaded, 102 | LoadNextPageError> { 103 | static const Sextet _factory = 105 | Sextet(); 107 | 108 | PartialStateChange._( 109 | Union6 111 | union) 112 | : super(union); 113 | 114 | factory PartialStateChange.firstPageLoading() { 115 | return PartialStateChange._(_factory.first(const LoadingFirstPage())); 116 | } 117 | 118 | factory PartialStateChange.firstPageError({ 119 | @required Object error, 120 | @required String textQuery, 121 | }) { 122 | return PartialStateChange._( 123 | _factory.second( 124 | LoadFirstPageError( 125 | error: error, 126 | textQuery: textQuery, 127 | ), 128 | ), 129 | ); 130 | } 131 | 132 | factory PartialStateChange.firstPageLoaded({ 133 | @required List books, 134 | @required String textQuery, 135 | }) { 136 | return PartialStateChange._(_factory.third( 137 | FirstPageLoaded( 138 | books: books, 139 | textQuery: textQuery, 140 | ), 141 | )); 142 | } 143 | 144 | factory PartialStateChange.nextPageLoading() { 145 | return PartialStateChange._(_factory.fourth(const LoadingNextPage())); 146 | } 147 | 148 | factory PartialStateChange.nextPageLoaded({ 149 | @required List books, 150 | @required String textQuery, 151 | }) { 152 | return PartialStateChange._( 153 | _factory.fifth( 154 | NextPageLoaded( 155 | textQuery: textQuery, 156 | books: books, 157 | ), 158 | ), 159 | ); 160 | } 161 | 162 | factory PartialStateChange.nextPageError({ 163 | @required Object error, 164 | @required String textQuery, 165 | }) { 166 | return PartialStateChange._( 167 | _factory.sixth( 168 | LoadNextPageError( 169 | textQuery: textQuery, 170 | error: error, 171 | ), 172 | ), 173 | ); 174 | } 175 | 176 | /// Pure function, produce new state from previous state [state] and partial state change [partialChange] 177 | HomePageState reduce(HomePageState state) { 178 | return join( 179 | (LoadingFirstPage change) { 180 | return state.rebuild((b) => b..isFirstPageLoading = true); 181 | }, 182 | (LoadFirstPageError change) { 183 | return state.rebuild((b) => b 184 | ..resultText = "Search for '${change.textQuery}', error occurred" 185 | ..isFirstPageLoading = false 186 | ..loadFirstPageError = change.error 187 | ..isNextPageLoading = false 188 | ..loadNextPageError = null 189 | ..books = ListBuilder()); 190 | }, 191 | (FirstPageLoaded change) { 192 | return state.rebuild((b) => b 193 | ..resultText = 194 | "Search for '${change.textQuery}', have ${change.books.length} books" 195 | ..books = ListBuilder(change.books) 196 | ..isFirstPageLoading = false 197 | ..isNextPageLoading = false 198 | ..loadFirstPageError = null 199 | ..loadNextPageError = null); 200 | }, 201 | (LoadingNextPage change) { 202 | return state.rebuild((b) => b..isNextPageLoading = true); 203 | }, 204 | (NextPageLoaded change) { 205 | return state.rebuild((b) { 206 | var newListBuilder = b.books..addAll(change.books); 207 | return b 208 | ..books = newListBuilder 209 | ..resultText = 210 | "Search for '${change.textQuery}', have ${newListBuilder.length} books" 211 | ..isNextPageLoading = false 212 | ..loadNextPageError = null; 213 | }); 214 | }, 215 | (LoadNextPageError change) { 216 | return state.rebuild((b) => b 217 | ..resultText = 218 | "Search for '${change.textQuery}', have ${state.books.length} books" 219 | ..isNextPageLoading = false 220 | ..loadNextPageError = change.error); 221 | }, 222 | ); 223 | } 224 | 225 | @override 226 | String toString() => join( 227 | _toString, _toString, _toString, _toString, _toString, _toString); 228 | } 229 | 230 | class LoadingFirstPage { 231 | const LoadingFirstPage(); 232 | 233 | @override 234 | String toString() => 'LoadingFirstPage'; 235 | } 236 | 237 | class LoadFirstPageError { 238 | final Object error; 239 | final String textQuery; 240 | 241 | const LoadFirstPageError({@required this.error, @required this.textQuery}); 242 | 243 | @override 244 | String toString() => 'LoadFirstPageError(error=$error,textQuery=$textQuery)'; 245 | } 246 | 247 | class FirstPageLoaded { 248 | final List books; 249 | final String textQuery; 250 | 251 | const FirstPageLoaded({@required this.books, @required this.textQuery}); 252 | 253 | @override 254 | String toString() => 255 | 'FirstPageLoaded(books.length=${books.length},textQuery=$textQuery)'; 256 | } 257 | 258 | class LoadingNextPage { 259 | const LoadingNextPage(); 260 | 261 | @override 262 | String toString() => 'LoadingNextPage'; 263 | } 264 | 265 | class NextPageLoaded { 266 | final List books; 267 | final String textQuery; 268 | 269 | const NextPageLoaded({@required this.books, @required this.textQuery}); 270 | 271 | @override 272 | String toString() => 273 | 'NextPageLoaded(books.length=${books.length},textQuery=$textQuery)'; 274 | } 275 | 276 | class LoadNextPageError { 277 | final Object error; 278 | final String textQuery; 279 | 280 | const LoadNextPageError({@required this.error, @required this.textQuery}); 281 | 282 | @override 283 | String toString() => 'LoadNextPageError(error=$error,textQuery=$textQuery)'; 284 | } 285 | 286 | /// 287 | /// Home Intent (Action) 288 | /// 289 | class HomeIntent extends Union2Impl { 290 | static const Doublet _factory = 291 | Doublet(); 292 | 293 | HomeIntent._(Union2 union) : super(union); 294 | 295 | factory HomeIntent.searchIntent({@required String search}) { 296 | return HomeIntent._(_factory.first(SearchIntent(search: search))); 297 | } 298 | 299 | factory HomeIntent.loadNextPageIntent({ 300 | @required String search, 301 | @required int startIndex, 302 | }) { 303 | return HomeIntent._( 304 | _factory.second( 305 | LoadNextPageIntent( 306 | search: search, 307 | startIndex: startIndex, 308 | ), 309 | ), 310 | ); 311 | } 312 | 313 | @override 314 | String toString() => join(_toString, _toString); 315 | } 316 | 317 | class SearchIntent { 318 | final String search; 319 | 320 | const SearchIntent({@required this.search}); 321 | 322 | @override 323 | String toString() => 'SearchIntent(search=$search)'; 324 | } 325 | 326 | class LoadNextPageIntent { 327 | final String search; 328 | final int startIndex; 329 | 330 | const LoadNextPageIntent({@required this.search, @required this.startIndex}); 331 | 332 | @override 333 | String toString() => 334 | 'LoadNextPageIntent(search=$search,startIndex=$startIndex)'; 335 | } 336 | 337 | @immutable 338 | abstract class HomePageMessage { 339 | factory HomePageMessage.fromResult(ToggleFavResult result, BookItem book) { 340 | if (result.added) { 341 | if (result.result) { 342 | return AddToFavoriteSuccess(book); 343 | } else { 344 | return AddToFavoriteFailure(book, result.error); 345 | } 346 | } else { 347 | if (result.result) { 348 | return RemoveFromFavoriteSuccess(book); 349 | } else { 350 | return RemoveFromFavoriteFailure(book, result.error); 351 | } 352 | } 353 | } 354 | } 355 | 356 | class AddToFavoriteSuccess implements HomePageMessage { 357 | final BookItem item; 358 | 359 | const AddToFavoriteSuccess(this.item); 360 | 361 | @override 362 | String toString() => 'AddToFavoriteSuccess{item=$item}'; 363 | } 364 | 365 | class AddToFavoriteFailure implements HomePageMessage { 366 | final BookItem item; 367 | final error; 368 | 369 | const AddToFavoriteFailure(this.item, this.error); 370 | 371 | @override 372 | String toString() => 'AddToFavoriteFailure{item=$item, error=$error}'; 373 | } 374 | 375 | class RemoveFromFavoriteSuccess implements HomePageMessage { 376 | final BookItem item; 377 | 378 | const RemoveFromFavoriteSuccess(this.item); 379 | 380 | @override 381 | String toString() => 'RemoveFromFavoriteSuccess{item=$item}'; 382 | } 383 | 384 | class RemoveFromFavoriteFailure implements HomePageMessage { 385 | final BookItem item; 386 | final error; 387 | 388 | const RemoveFromFavoriteFailure(this.item, this.error); 389 | 390 | @override 391 | String toString() => 'RemoveFromFavoriteFailure{item=$item, error=$error}'; 392 | } 393 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | analyzer: 5 | dependency: transitive 6 | description: 7 | name: analyzer 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "0.38.5" 11 | analyzer_plugin: 12 | dependency: transitive 13 | description: 14 | name: analyzer_plugin 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "0.2.1" 18 | archive: 19 | dependency: transitive 20 | description: 21 | name: archive 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "2.0.11" 25 | args: 26 | dependency: transitive 27 | description: 28 | name: args 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "1.5.2" 32 | async: 33 | dependency: transitive 34 | description: 35 | name: async 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "2.4.0" 39 | boolean_selector: 40 | dependency: transitive 41 | description: 42 | name: boolean_selector 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "1.0.5" 46 | build: 47 | dependency: transitive 48 | description: 49 | name: build 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "1.2.2" 53 | build_config: 54 | dependency: transitive 55 | description: 56 | name: build_config 57 | url: "https://pub.dartlang.org" 58 | source: hosted 59 | version: "0.4.1+1" 60 | build_daemon: 61 | dependency: transitive 62 | description: 63 | name: build_daemon 64 | url: "https://pub.dartlang.org" 65 | source: hosted 66 | version: "2.1.2" 67 | build_resolvers: 68 | dependency: transitive 69 | description: 70 | name: build_resolvers 71 | url: "https://pub.dartlang.org" 72 | source: hosted 73 | version: "1.2.1" 74 | build_runner: 75 | dependency: "direct dev" 76 | description: 77 | name: build_runner 78 | url: "https://pub.dartlang.org" 79 | source: hosted 80 | version: "1.7.2" 81 | build_runner_core: 82 | dependency: transitive 83 | description: 84 | name: build_runner_core 85 | url: "https://pub.dartlang.org" 86 | source: hosted 87 | version: "4.3.0" 88 | built_collection: 89 | dependency: transitive 90 | description: 91 | name: built_collection 92 | url: "https://pub.dartlang.org" 93 | source: hosted 94 | version: "4.3.0" 95 | built_value: 96 | dependency: "direct main" 97 | description: 98 | name: built_value 99 | url: "https://pub.dartlang.org" 100 | source: hosted 101 | version: "7.0.0" 102 | built_value_generator: 103 | dependency: "direct dev" 104 | description: 105 | name: built_value_generator 106 | url: "https://pub.dartlang.org" 107 | source: hosted 108 | version: "7.0.0" 109 | charcode: 110 | dependency: transitive 111 | description: 112 | name: charcode 113 | url: "https://pub.dartlang.org" 114 | source: hosted 115 | version: "1.1.2" 116 | checked_yaml: 117 | dependency: transitive 118 | description: 119 | name: checked_yaml 120 | url: "https://pub.dartlang.org" 121 | source: hosted 122 | version: "1.0.2" 123 | code_builder: 124 | dependency: transitive 125 | description: 126 | name: code_builder 127 | url: "https://pub.dartlang.org" 128 | source: hosted 129 | version: "3.2.1" 130 | collection: 131 | dependency: "direct main" 132 | description: 133 | name: collection 134 | url: "https://pub.dartlang.org" 135 | source: hosted 136 | version: "1.14.11" 137 | convert: 138 | dependency: transitive 139 | description: 140 | name: convert 141 | url: "https://pub.dartlang.org" 142 | source: hosted 143 | version: "2.1.1" 144 | crypto: 145 | dependency: transitive 146 | description: 147 | name: crypto 148 | url: "https://pub.dartlang.org" 149 | source: hosted 150 | version: "2.1.3" 151 | csslib: 152 | dependency: transitive 153 | description: 154 | name: csslib 155 | url: "https://pub.dartlang.org" 156 | source: hosted 157 | version: "0.16.1" 158 | cupertino_icons: 159 | dependency: "direct main" 160 | description: 161 | name: cupertino_icons 162 | url: "https://pub.dartlang.org" 163 | source: hosted 164 | version: "0.1.3" 165 | dart_style: 166 | dependency: transitive 167 | description: 168 | name: dart_style 169 | url: "https://pub.dartlang.org" 170 | source: hosted 171 | version: "1.3.3" 172 | disposebag: 173 | dependency: "direct main" 174 | description: 175 | name: disposebag 176 | url: "https://pub.dartlang.org" 177 | source: hosted 178 | version: "1.0.0+1" 179 | distinct_value_connectable_stream: 180 | dependency: "direct main" 181 | description: 182 | name: distinct_value_connectable_stream 183 | url: "https://pub.dartlang.org" 184 | source: hosted 185 | version: "1.0.3" 186 | fixnum: 187 | dependency: transitive 188 | description: 189 | name: fixnum 190 | url: "https://pub.dartlang.org" 191 | source: hosted 192 | version: "0.10.11" 193 | flutter: 194 | dependency: "direct main" 195 | description: flutter 196 | source: sdk 197 | version: "0.0.0" 198 | flutter_bloc_pattern: 199 | dependency: "direct main" 200 | description: 201 | name: flutter_bloc_pattern 202 | url: "https://pub.dartlang.org" 203 | source: hosted 204 | version: "1.1.0" 205 | flutter_html: 206 | dependency: "direct main" 207 | description: 208 | name: flutter_html 209 | url: "https://pub.dartlang.org" 210 | source: hosted 211 | version: "0.11.1" 212 | flutter_provider: 213 | dependency: "direct main" 214 | description: 215 | name: flutter_provider 216 | url: "https://pub.dartlang.org" 217 | source: hosted 218 | version: "1.1.1" 219 | flutter_test: 220 | dependency: "direct dev" 221 | description: flutter 222 | source: sdk 223 | version: "0.0.0" 224 | flutter_web_plugins: 225 | dependency: transitive 226 | description: flutter 227 | source: sdk 228 | version: "0.0.0" 229 | front_end: 230 | dependency: transitive 231 | description: 232 | name: front_end 233 | url: "https://pub.dartlang.org" 234 | source: hosted 235 | version: "0.1.27" 236 | glob: 237 | dependency: transitive 238 | description: 239 | name: glob 240 | url: "https://pub.dartlang.org" 241 | source: hosted 242 | version: "1.2.0" 243 | graphs: 244 | dependency: transitive 245 | description: 246 | name: graphs 247 | url: "https://pub.dartlang.org" 248 | source: hosted 249 | version: "0.2.0" 250 | html: 251 | dependency: transitive 252 | description: 253 | name: html 254 | url: "https://pub.dartlang.org" 255 | source: hosted 256 | version: "0.14.0+3" 257 | http: 258 | dependency: "direct main" 259 | description: 260 | name: http 261 | url: "https://pub.dartlang.org" 262 | source: hosted 263 | version: "0.12.0+2" 264 | http_multi_server: 265 | dependency: transitive 266 | description: 267 | name: http_multi_server 268 | url: "https://pub.dartlang.org" 269 | source: hosted 270 | version: "2.1.0" 271 | http_parser: 272 | dependency: transitive 273 | description: 274 | name: http_parser 275 | url: "https://pub.dartlang.org" 276 | source: hosted 277 | version: "3.1.3" 278 | image: 279 | dependency: transitive 280 | description: 281 | name: image 282 | url: "https://pub.dartlang.org" 283 | source: hosted 284 | version: "2.1.4" 285 | io: 286 | dependency: transitive 287 | description: 288 | name: io 289 | url: "https://pub.dartlang.org" 290 | source: hosted 291 | version: "0.3.3" 292 | js: 293 | dependency: transitive 294 | description: 295 | name: js 296 | url: "https://pub.dartlang.org" 297 | source: hosted 298 | version: "0.6.1+1" 299 | json_annotation: 300 | dependency: transitive 301 | description: 302 | name: json_annotation 303 | url: "https://pub.dartlang.org" 304 | source: hosted 305 | version: "3.0.0" 306 | kernel: 307 | dependency: transitive 308 | description: 309 | name: kernel 310 | url: "https://pub.dartlang.org" 311 | source: hosted 312 | version: "0.3.27" 313 | logging: 314 | dependency: transitive 315 | description: 316 | name: logging 317 | url: "https://pub.dartlang.org" 318 | source: hosted 319 | version: "0.11.3+2" 320 | matcher: 321 | dependency: transitive 322 | description: 323 | name: matcher 324 | url: "https://pub.dartlang.org" 325 | source: hosted 326 | version: "0.12.6" 327 | meta: 328 | dependency: transitive 329 | description: 330 | name: meta 331 | url: "https://pub.dartlang.org" 332 | source: hosted 333 | version: "1.1.8" 334 | mime: 335 | dependency: transitive 336 | description: 337 | name: mime 338 | url: "https://pub.dartlang.org" 339 | source: hosted 340 | version: "0.9.6+3" 341 | mockito: 342 | dependency: "direct dev" 343 | description: 344 | name: mockito 345 | url: "https://pub.dartlang.org" 346 | source: hosted 347 | version: "4.1.1" 348 | node_interop: 349 | dependency: transitive 350 | description: 351 | name: node_interop 352 | url: "https://pub.dartlang.org" 353 | source: hosted 354 | version: "1.0.3" 355 | node_io: 356 | dependency: transitive 357 | description: 358 | name: node_io 359 | url: "https://pub.dartlang.org" 360 | source: hosted 361 | version: "1.0.1+2" 362 | package_config: 363 | dependency: transitive 364 | description: 365 | name: package_config 366 | url: "https://pub.dartlang.org" 367 | source: hosted 368 | version: "1.1.0" 369 | package_resolver: 370 | dependency: transitive 371 | description: 372 | name: package_resolver 373 | url: "https://pub.dartlang.org" 374 | source: hosted 375 | version: "1.0.10" 376 | path: 377 | dependency: transitive 378 | description: 379 | name: path 380 | url: "https://pub.dartlang.org" 381 | source: hosted 382 | version: "1.6.4" 383 | pedantic: 384 | dependency: transitive 385 | description: 386 | name: pedantic 387 | url: "https://pub.dartlang.org" 388 | source: hosted 389 | version: "1.8.0+1" 390 | petitparser: 391 | dependency: transitive 392 | description: 393 | name: petitparser 394 | url: "https://pub.dartlang.org" 395 | source: hosted 396 | version: "2.4.0" 397 | pool: 398 | dependency: transitive 399 | description: 400 | name: pool 401 | url: "https://pub.dartlang.org" 402 | source: hosted 403 | version: "1.4.0" 404 | pub_semver: 405 | dependency: transitive 406 | description: 407 | name: pub_semver 408 | url: "https://pub.dartlang.org" 409 | source: hosted 410 | version: "1.4.2" 411 | pubspec_parse: 412 | dependency: transitive 413 | description: 414 | name: pubspec_parse 415 | url: "https://pub.dartlang.org" 416 | source: hosted 417 | version: "0.1.5" 418 | quiver: 419 | dependency: transitive 420 | description: 421 | name: quiver 422 | url: "https://pub.dartlang.org" 423 | source: hosted 424 | version: "2.0.5" 425 | rx_shared_preferences: 426 | dependency: "direct main" 427 | description: 428 | name: rx_shared_preferences 429 | url: "https://pub.dartlang.org" 430 | source: hosted 431 | version: "1.1.0" 432 | rxdart: 433 | dependency: transitive 434 | description: 435 | name: rxdart 436 | url: "https://pub.dartlang.org" 437 | source: hosted 438 | version: "0.23.1" 439 | sealed_unions: 440 | dependency: "direct main" 441 | description: 442 | name: sealed_unions 443 | url: "https://pub.dartlang.org" 444 | source: hosted 445 | version: "3.0.2+2" 446 | shared_preferences: 447 | dependency: transitive 448 | description: 449 | name: shared_preferences 450 | url: "https://pub.dartlang.org" 451 | source: hosted 452 | version: "0.5.6" 453 | shared_preferences_macos: 454 | dependency: transitive 455 | description: 456 | name: shared_preferences_macos 457 | url: "https://pub.dartlang.org" 458 | source: hosted 459 | version: "0.0.1+3" 460 | shared_preferences_platform_interface: 461 | dependency: transitive 462 | description: 463 | name: shared_preferences_platform_interface 464 | url: "https://pub.dartlang.org" 465 | source: hosted 466 | version: "1.0.1" 467 | shared_preferences_web: 468 | dependency: transitive 469 | description: 470 | name: shared_preferences_web 471 | url: "https://pub.dartlang.org" 472 | source: hosted 473 | version: "0.1.2+2" 474 | shelf: 475 | dependency: transitive 476 | description: 477 | name: shelf 478 | url: "https://pub.dartlang.org" 479 | source: hosted 480 | version: "0.7.5" 481 | shelf_web_socket: 482 | dependency: transitive 483 | description: 484 | name: shelf_web_socket 485 | url: "https://pub.dartlang.org" 486 | source: hosted 487 | version: "0.2.3" 488 | sky_engine: 489 | dependency: transitive 490 | description: flutter 491 | source: sdk 492 | version: "0.0.99" 493 | source_gen: 494 | dependency: transitive 495 | description: 496 | name: source_gen 497 | url: "https://pub.dartlang.org" 498 | source: hosted 499 | version: "0.9.4+6" 500 | source_span: 501 | dependency: transitive 502 | description: 503 | name: source_span 504 | url: "https://pub.dartlang.org" 505 | source: hosted 506 | version: "1.5.5" 507 | stack_trace: 508 | dependency: transitive 509 | description: 510 | name: stack_trace 511 | url: "https://pub.dartlang.org" 512 | source: hosted 513 | version: "1.9.3" 514 | stream_channel: 515 | dependency: transitive 516 | description: 517 | name: stream_channel 518 | url: "https://pub.dartlang.org" 519 | source: hosted 520 | version: "2.0.0" 521 | stream_transform: 522 | dependency: transitive 523 | description: 524 | name: stream_transform 525 | url: "https://pub.dartlang.org" 526 | source: hosted 527 | version: "0.0.20" 528 | string_scanner: 529 | dependency: transitive 530 | description: 531 | name: string_scanner 532 | url: "https://pub.dartlang.org" 533 | source: hosted 534 | version: "1.0.5" 535 | term_glyph: 536 | dependency: transitive 537 | description: 538 | name: term_glyph 539 | url: "https://pub.dartlang.org" 540 | source: hosted 541 | version: "1.1.0" 542 | test_api: 543 | dependency: transitive 544 | description: 545 | name: test_api 546 | url: "https://pub.dartlang.org" 547 | source: hosted 548 | version: "0.2.11" 549 | timing: 550 | dependency: transitive 551 | description: 552 | name: timing 553 | url: "https://pub.dartlang.org" 554 | source: hosted 555 | version: "0.1.1+2" 556 | tuple: 557 | dependency: "direct main" 558 | description: 559 | name: tuple 560 | url: "https://pub.dartlang.org" 561 | source: hosted 562 | version: "1.0.3" 563 | typed_data: 564 | dependency: transitive 565 | description: 566 | name: typed_data 567 | url: "https://pub.dartlang.org" 568 | source: hosted 569 | version: "1.1.6" 570 | vector_math: 571 | dependency: transitive 572 | description: 573 | name: vector_math 574 | url: "https://pub.dartlang.org" 575 | source: hosted 576 | version: "2.0.8" 577 | watcher: 578 | dependency: transitive 579 | description: 580 | name: watcher 581 | url: "https://pub.dartlang.org" 582 | source: hosted 583 | version: "0.9.7+13" 584 | web_socket_channel: 585 | dependency: transitive 586 | description: 587 | name: web_socket_channel 588 | url: "https://pub.dartlang.org" 589 | source: hosted 590 | version: "1.1.0" 591 | xml: 592 | dependency: transitive 593 | description: 594 | name: xml 595 | url: "https://pub.dartlang.org" 596 | source: hosted 597 | version: "3.5.0" 598 | yaml: 599 | dependency: transitive 600 | description: 601 | name: yaml 602 | url: "https://pub.dartlang.org" 603 | source: hosted 604 | version: "2.2.0" 605 | sdks: 606 | dart: ">=2.6.0 <3.0.0" 607 | flutter: ">=1.12.13+hotfix.4 <2.0.0" 608 | -------------------------------------------------------------------------------- /lib/pages/home_page/home_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:built_collection/built_collection.dart'; 5 | import 'package:search_book/domain/book_repo.dart'; 6 | import 'package:search_book/widgets/fav_count_badge.dart'; 7 | import 'package:search_book/pages/detail_page/detail_bloc.dart'; 8 | import 'package:search_book/pages/detail_page/detail_page.dart'; 9 | import 'package:search_book/pages/fav_page/fav_books_bloc.dart'; 10 | import 'package:search_book/pages/fav_page/fav_books_page.dart'; 11 | import 'package:search_book/pages/home_page/home_bloc.dart'; 12 | import 'package:search_book/pages/home_page/home_state.dart'; 13 | import 'package:search_book/domain/favorited_books_repo.dart'; 14 | import 'package:flutter/material.dart'; 15 | import 'package:flutter_bloc_pattern/flutter_bloc_pattern.dart'; 16 | import 'package:flutter_provider/flutter_provider.dart'; 17 | 18 | class MyHomePage extends StatefulWidget { 19 | @override 20 | _MyHomePageState createState() => _MyHomePageState(); 21 | } 22 | 23 | class _MyHomePageState extends State { 24 | final _scaffoldKey = GlobalKey(); 25 | StreamSubscription _subscription; 26 | 27 | @override 28 | void didChangeDependencies() { 29 | super.didChangeDependencies(); 30 | 31 | _subscription ??= 32 | BlocProvider.of(context).message$.listen((message) { 33 | if (message is AddToFavoriteSuccess) { 34 | _showSnackBar('Add `${message.item?.title}` to fav success'); 35 | } 36 | if (message is AddToFavoriteFailure) { 37 | _showSnackBar( 38 | 'Add `${message.item?.title}` to fav failure: ${message.error ?? 'Unknown error'}'); 39 | } 40 | if (message is RemoveFromFavoriteSuccess) { 41 | _showSnackBar('Remove `${message.item?.title}` from fav success'); 42 | } 43 | if (message is RemoveFromFavoriteFailure) { 44 | _showSnackBar( 45 | 'Remove `${message.item?.title}` from fav failure: ${message.error ?? 'Unknown error'}'); 46 | } 47 | }); 48 | } 49 | 50 | Future _showSnackBar(String msg) => _scaffoldKey.currentState 51 | ?.showSnackBar( 52 | SnackBar( 53 | content: Text(msg), 54 | duration: const Duration(seconds: 2), 55 | ), 56 | ) 57 | ?.closed; 58 | 59 | @override 60 | void dispose() { 61 | _subscription?.cancel(); 62 | super.dispose(); 63 | } 64 | 65 | @override 66 | Widget build(BuildContext context) { 67 | final bloc = BlocProvider.of(context); 68 | 69 | final searchTextField = Padding( 70 | padding: const EdgeInsets.all(8), 71 | child: TextField( 72 | decoration: InputDecoration( 73 | labelText: 'Search book...', 74 | contentPadding: EdgeInsets.all(12.0), 75 | filled: true, 76 | prefixIcon: Icon(Icons.book), 77 | ), 78 | maxLines: 1, 79 | onChanged: bloc.changeQuery, 80 | keyboardType: TextInputType.text, 81 | textInputAction: TextInputAction.search, 82 | ), 83 | ); 84 | 85 | return Scaffold( 86 | key: _scaffoldKey, 87 | floatingActionButton: FloatingActionButton( 88 | heroTag: 'FAV_COUNT', 89 | tooltip: 'Favorite page', 90 | onPressed: () { 91 | Navigator.push( 92 | context, 93 | MaterialPageRoute( 94 | builder: (context) { 95 | return Consumer2( 96 | builder: (context, sharedPref, bookRepo) { 97 | return BlocProvider( 98 | initBloc: () { 99 | return FavBooksBloc( 100 | FavBooksInteractor( 101 | bookRepo, 102 | sharedPref, 103 | ), 104 | ); 105 | }, 106 | child: FavoritedBooksPage(), 107 | ); 108 | }, 109 | ); 110 | }, 111 | ), 112 | ); 113 | }, 114 | child: Stack( 115 | children: [ 116 | Align( 117 | alignment: AlignmentDirectional.center, 118 | child: Icon( 119 | Icons.favorite, 120 | ), 121 | ), 122 | StreamBuilder( 123 | stream: bloc.favoriteCount$, 124 | initialData: bloc.favoriteCount$.value, 125 | builder: (context, snapshot) { 126 | return Positioned( 127 | top: 0, 128 | right: 0, 129 | child: FavCountBadge( 130 | key: ValueKey(snapshot.data), 131 | count: snapshot.data, 132 | ), 133 | ); 134 | }, 135 | ), 136 | ], 137 | ), 138 | ), 139 | body: Container( 140 | padding: EdgeInsets.only( 141 | left: 8.0, 142 | right: 8.0, 143 | bottom: 8.0, 144 | top: MediaQuery.of(context).padding.top, 145 | ), 146 | decoration: BoxDecoration( 147 | gradient: LinearGradient( 148 | colors: [ 149 | Colors.teal.withOpacity(0.9), 150 | Colors.deepPurpleAccent.withOpacity(0.9), 151 | ], 152 | begin: AlignmentDirectional.topStart, 153 | end: AlignmentDirectional.bottomEnd, 154 | stops: [0.3, 0.7], 155 | ), 156 | ), 157 | child: Column( 158 | children: [ 159 | searchTextField, 160 | Expanded( 161 | child: StreamBuilder( 162 | stream: bloc.state$, 163 | initialData: bloc.state$.value, 164 | builder: (context, snapshot) { 165 | final HomePageState data = snapshot.data; 166 | 167 | return Column( 168 | mainAxisSize: MainAxisSize.max, 169 | mainAxisAlignment: MainAxisAlignment.spaceAround, 170 | children: [ 171 | Text( 172 | data.resultText ?? '', 173 | maxLines: 2, 174 | textAlign: TextAlign.center, 175 | overflow: TextOverflow.ellipsis, 176 | style: Theme.of(context) 177 | .textTheme 178 | .body1 179 | .copyWith(fontSize: 15), 180 | ), 181 | SizedBox(height: 8), 182 | data.isFirstPageLoading 183 | ? Padding( 184 | padding: const EdgeInsets.only( 185 | bottom: 16.0, 186 | top: 8.0, 187 | ), 188 | child: CircularProgressIndicator(), 189 | ) 190 | : Container(), 191 | Expanded(child: HomeListViewWidget(state: data)) 192 | ], 193 | ); 194 | }, 195 | ), 196 | ), 197 | ], 198 | ), 199 | ), 200 | ); 201 | } 202 | } 203 | 204 | class HomeListViewWidget extends StatelessWidget { 205 | final HomePageState state; 206 | 207 | const HomeListViewWidget({Key key, @required this.state}) 208 | : assert(state != null), 209 | super(key: key); 210 | 211 | @override 212 | Widget build(BuildContext context) { 213 | final bloc = BlocProvider.of(context); 214 | 215 | if (state.loadFirstPageError != null) { 216 | final error = state.loadFirstPageError; 217 | 218 | return SingleChildScrollView( 219 | child: Column( 220 | mainAxisSize: MainAxisSize.max, 221 | mainAxisAlignment: MainAxisAlignment.center, 222 | crossAxisAlignment: CrossAxisAlignment.stretch, 223 | children: [ 224 | Text( 225 | error is HttpException 226 | ? error.message 227 | : 'An error occurred $error', 228 | textAlign: TextAlign.center, 229 | maxLines: 2, 230 | style: Theme.of(context).textTheme.body1.copyWith(fontSize: 15), 231 | ), 232 | SizedBox(height: 16), 233 | RaisedButton( 234 | shape: RoundedRectangleBorder( 235 | borderRadius: BorderRadius.circular(16), 236 | ), 237 | child: Text( 238 | 'Retry', 239 | style: Theme.of(context).textTheme.body1.copyWith(fontSize: 16), 240 | ), 241 | padding: EdgeInsets.all(16.0), 242 | onPressed: bloc.retryFirstPage, 243 | ), 244 | ], 245 | ), 246 | ); 247 | } 248 | 249 | final BuiltList items = state.books; 250 | 251 | if (items.isEmpty) { 252 | return Container( 253 | constraints: BoxConstraints.expand(), 254 | child: Center( 255 | child: Text( 256 | 'Empty search result. Try other?', 257 | textAlign: TextAlign.center, 258 | style: Theme.of(context).textTheme.body1.copyWith(fontSize: 15), 259 | ), 260 | ), 261 | ); 262 | } 263 | 264 | return ListView.builder( 265 | itemCount: items.length + 1, 266 | padding: const EdgeInsets.all(0), 267 | physics: const BouncingScrollPhysics(), 268 | itemBuilder: (context, index) { 269 | if (index < items.length) { 270 | final item = items[index]; 271 | return HomeBookItemWidget( 272 | book: item, 273 | key: Key(item.id), 274 | ); 275 | } 276 | 277 | if (state.loadNextPageError != null) { 278 | final Object error = state.loadNextPageError; 279 | 280 | return Padding( 281 | padding: const EdgeInsets.all(8.0), 282 | child: Column( 283 | mainAxisAlignment: MainAxisAlignment.center, 284 | mainAxisSize: MainAxisSize.min, 285 | crossAxisAlignment: CrossAxisAlignment.stretch, 286 | children: [ 287 | Text( 288 | error is HttpException 289 | ? error.message 290 | : 'An error occurred $error', 291 | textAlign: TextAlign.center, 292 | maxLines: 2, 293 | style: 294 | Theme.of(context).textTheme.body1.copyWith(fontSize: 15), 295 | ), 296 | SizedBox(height: 8), 297 | RaisedButton( 298 | shape: RoundedRectangleBorder( 299 | borderRadius: BorderRadius.circular(16), 300 | ), 301 | onPressed: bloc.retryNextPage, 302 | padding: const EdgeInsets.all(16.0), 303 | child: Text( 304 | 'Retry', 305 | style: Theme.of(context) 306 | .textTheme 307 | .body1 308 | .copyWith(fontSize: 16), 309 | ), 310 | elevation: 4.0, 311 | ), 312 | ], 313 | ), 314 | ); 315 | } 316 | 317 | if (state.isNextPageLoading) { 318 | return Padding( 319 | padding: const EdgeInsets.all(16.0), 320 | child: Center( 321 | child: CircularProgressIndicator( 322 | strokeWidth: 2.0, 323 | ), 324 | ), 325 | ); 326 | } 327 | 328 | if (items.isNotEmpty) { 329 | return Padding( 330 | padding: const EdgeInsets.all(4.0), 331 | child: RaisedButton( 332 | shape: RoundedRectangleBorder( 333 | borderRadius: BorderRadius.circular(16), 334 | ), 335 | onPressed: bloc.loadNextPage, 336 | padding: EdgeInsets.all(16.0), 337 | child: Text( 338 | 'Load next page', 339 | style: Theme.of(context).textTheme.body1.copyWith(fontSize: 16), 340 | ), 341 | elevation: 4.0, 342 | ), 343 | ); 344 | } 345 | 346 | return Container(); 347 | }, 348 | ); 349 | } 350 | } 351 | 352 | class HomeBookItemWidget extends StatefulWidget { 353 | final BookItem book; 354 | 355 | const HomeBookItemWidget({Key key, @required this.book}) 356 | : assert(book != null), 357 | super(key: key); 358 | 359 | @override 360 | _HomeBookItemWidgetState createState() => _HomeBookItemWidgetState(); 361 | } 362 | 363 | class _HomeBookItemWidgetState extends State 364 | with SingleTickerProviderStateMixin { 365 | AnimationController _animController; 366 | Animation _position; 367 | Animation _scale; 368 | Animation _opacity; 369 | 370 | @override 371 | void initState() { 372 | super.initState(); 373 | 374 | _animController = AnimationController( 375 | vsync: this, 376 | duration: const Duration(milliseconds: 400), 377 | ); 378 | 379 | _position = Tween( 380 | begin: const Offset(1.5, 0), 381 | end: Offset.zero, 382 | ).animate( 383 | CurvedAnimation( 384 | parent: _animController, 385 | curve: Curves.fastOutSlowIn, 386 | ), 387 | ); 388 | _scale = Tween( 389 | begin: 0, 390 | end: 1, 391 | ).animate( 392 | CurvedAnimation( 393 | parent: _animController, 394 | curve: Curves.easeIn, 395 | ), 396 | ); 397 | _opacity = Tween( 398 | begin: 0, 399 | end: 1, 400 | ).animate( 401 | CurvedAnimation( 402 | parent: _animController, 403 | curve: Curves.easeInCubic, 404 | ), 405 | ); 406 | 407 | _animController.forward(); 408 | } 409 | 410 | @override 411 | void dispose() { 412 | _animController.dispose(); 413 | super.dispose(); 414 | } 415 | 416 | @override 417 | Widget build(BuildContext context) { 418 | final _book = widget.book; 419 | final bloc = BlocProvider.of(context); 420 | final textTheme = Theme.of(context).textTheme; 421 | 422 | return FadeTransition( 423 | opacity: _opacity, 424 | child: SlideTransition( 425 | position: _position, 426 | child: ScaleTransition( 427 | scale: _scale, 428 | child: Container( 429 | decoration: BoxDecoration( 430 | boxShadow: [ 431 | BoxShadow( 432 | color: Colors.black26, 433 | offset: Offset(4, 4), 434 | blurRadius: 4, 435 | ) 436 | ], 437 | ), 438 | margin: const EdgeInsets.all(8), 439 | child: Material( 440 | color: Colors.white, 441 | borderRadius: BorderRadius.circular(8), 442 | child: InkWell( 443 | onTap: () { 444 | Navigator.push( 445 | context, 446 | MaterialPageRoute( 447 | builder: (BuildContext context) { 448 | return Consumer2( 449 | builder: (context, sharedPref, bookApi) { 450 | return DetailPage( 451 | initBloc: () { 452 | return DetailBloc( 453 | bookApi, 454 | sharedPref, 455 | _book.toDomain(), 456 | ); 457 | }, 458 | ); 459 | }, 460 | ); 461 | }, 462 | ), 463 | ); 464 | }, 465 | child: Row( 466 | crossAxisAlignment: CrossAxisAlignment.center, 467 | children: [ 468 | Container( 469 | decoration: BoxDecoration( 470 | borderRadius: BorderRadius.circular(8), 471 | boxShadow: [ 472 | BoxShadow( 473 | color: Colors.grey, 474 | offset: Offset(4, 4), 475 | blurRadius: 4, 476 | ) 477 | ], 478 | ), 479 | child: Hero( 480 | tag: _book.id, 481 | child: ClipRRect( 482 | borderRadius: BorderRadius.circular(8), 483 | clipBehavior: Clip.antiAlias, 484 | child: FadeInImage.assetNetwork( 485 | image: _book.thumbnail ?? '', 486 | width: 64.0 * 1.5, 487 | height: 96.0 * 1.5, 488 | fit: BoxFit.cover, 489 | placeholder: 'assets/no_image.png', 490 | ), 491 | ), 492 | ), 493 | ), 494 | SizedBox(width: 16), 495 | Expanded( 496 | child: Container( 497 | decoration: BoxDecoration( 498 | borderRadius: BorderRadius.only( 499 | bottomRight: Radius.circular(8), 500 | topRight: Radius.circular(8), 501 | ), 502 | ), 503 | child: Column( 504 | crossAxisAlignment: CrossAxisAlignment.start, 505 | children: [ 506 | Text( 507 | _book.title ?? 'No title', 508 | maxLines: 2, 509 | overflow: TextOverflow.ellipsis, 510 | style: textTheme.title.copyWith( 511 | fontSize: 18, 512 | fontWeight: FontWeight.w700, 513 | color: Colors.black87, 514 | ), 515 | ), 516 | SizedBox(height: 4.0), 517 | Text( 518 | _book.subtitle ?? 'No subtitle', 519 | maxLines: 1, 520 | overflow: TextOverflow.ellipsis, 521 | style: textTheme.subtitle.copyWith( 522 | color: Colors.black54, 523 | fontSize: 16, 524 | ), 525 | ), 526 | SizedBox(height: 8), 527 | _book.isFavorited == null 528 | ? SizedBox( 529 | width: 24, 530 | height: 24, 531 | child: Center( 532 | child: CircularProgressIndicator( 533 | strokeWidth: 2, 534 | ), 535 | ), 536 | ) 537 | : FloatingActionButton( 538 | heroTag: null, 539 | onPressed: () => 540 | bloc.toggleFavorited(_book.id), 541 | child: _book.isFavorited 542 | ? Icon( 543 | Icons.favorite, 544 | color: 545 | Theme.of(context).accentColor, 546 | ) 547 | : Icon( 548 | Icons.favorite_border, 549 | color: 550 | Theme.of(context).accentColor, 551 | ), 552 | elevation: 0, 553 | backgroundColor: Colors.transparent, 554 | tooltip: _book.isFavorited 555 | ? 'Remove from favorite' 556 | : 'Add to favorite', 557 | ), 558 | ], 559 | ), 560 | ), 561 | ) 562 | ], 563 | ), 564 | ), 565 | ), 566 | ), 567 | ), 568 | ), 569 | ); 570 | } 571 | } 572 | --------------------------------------------------------------------------------