├── .gitignore ├── README.md ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── doubanmovieflutter │ │ │ └── MainActivity.java │ │ └── res │ │ ├── drawable │ │ └── launch_background.xml │ │ ├── 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 ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── key.properties └── settings.gradle ├── doc └── TOC.md ├── images ├── flutter_logo.png ├── icon_account_normal.png ├── icon_account_selected.png ├── icon_api.png ├── icon_blog.png ├── icon_explore_normal.png ├── icon_explore_selected.png ├── icon_github.png ├── icon_hot_normal.png ├── icon_hot_selected.png ├── icon_more_normal.png ├── icon_more_selected.png ├── icon_qq.png ├── icon_ranking_down.png ├── icon_ranking_up.png ├── icon_upcoming_normal.png ├── icon_upcoming_selected.png └── icon_wx.png ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── 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-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── ImagePalette │ ├── ColorCutQuantizer.swift │ ├── ColorHistogram.swift │ ├── DefaultPaletteGenerator.swift │ ├── HSLColor.swift │ ├── HexColor.swift │ ├── Palette.swift │ ├── PaletteConfiguration.swift │ ├── PaletteGenerator.swift │ ├── PaletteSwatch.swift │ ├── RGBColor.swift │ ├── SwiftPriorityQueue.swift │ └── UIImage+PaletteExtension.swift │ ├── Info.plist │ ├── Runner-Bridging-Header.h │ └── SwiftPalettePlugin.swift ├── lib ├── CustomView.dart ├── NestScrollSample.dart ├── main.dart ├── model │ ├── MovieActor.dart │ ├── MovieActor.g.dart │ ├── MovieDetail.dart │ ├── MovieDetail.g.dart │ ├── MovieImgs.dart │ ├── MovieImgs.g.dart │ ├── MovieIntro.dart │ ├── MovieIntro.g.dart │ ├── MovieIntroList.dart │ ├── MovieIntroList.g.dart │ ├── MovieRate.dart │ └── MovieRate.g.dart ├── net │ ├── DateSource.dart │ └── api.dart ├── pages │ ├── explore_page.dart │ ├── hotlist_page.dart │ ├── more_page.dart │ ├── movie_detail_page.dart │ └── upcoming_page.dart ├── palette.dart └── utils │ └── NetUtils.dart ├── pubspec.yaml └── screenshots ├── IMG_0309.PNG ├── IMG_0310.PNG ├── IMG_0311.PNG ├── IMG_0312.PNG ├── IMG_0313.PNG ├── IMG_0319.PNG └── IMG_0320.PNG /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dart_tool/ 3 | .idea/ 4 | .packages 5 | .pub/ 6 | 7 | build/ 8 | 9 | .flutter-plugins 10 | .gitattributes 11 | .metadata 12 | 13 | doubanmovie_flutter.iml 14 | doubanmovie_flutter_android.iml 15 | pubspec.lock 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 使用豆瓣 Api 实现了一个精美的 Flutter 版豆瓣电影客户端,细节很完善,更像一个经过完整设计的产品而非 Demo,期间踩了不少坑,文档和功能会持续更新,文档包括从最开始环境安装到最后打包App的过程中涉及的知识点以及一些个人思考。 2 | 3 | [:blue_book:文档入口](https://github.com/zcoderr/Flutter_Douban/blob/master/doc/TOC.md) 4 | 5 | > 下载方式: 6 | 7 | **Android:** 8 | [APK 下载地址](https://fir.im/douba) 9 | 10 | 或 : 11 | 12 | 扫描二维码下载 13 | 14 |   15 | 16 | **iOS:** 17 | 暂无 18 | 19 | 20 | 21 | #### To-Do List: 22 | 23 | - [ ] 添加影人详情页面 24 | - [ ] 在电影详情页面添加热门评论 25 | - [ ] 电影详情页里添加电影海报模块 26 | - [ ] 添加评论详情页面 27 | 28 | 29 | 30 | > App 截图 31 | 32 |   33 |   34 |   35 |   36 |   37 |   38 |   39 | 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | throw new GradleException("versionCode not found. Define flutter.versionCode in the local.properties file.") 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | throw new GradleException("versionName not found. Define flutter.versionName in the local.properties file.") 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 26 | 27 | def keystorePropertiesFile = rootProject.file("key.properties") 28 | def keystoreProperties = new Properties() 29 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 30 | 31 | android { 32 | compileSdkVersion 28 33 | 34 | lintOptions { 35 | disable 'InvalidPackage' 36 | } 37 | 38 | defaultConfig { 39 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 40 | applicationId "com.example.doubanmovieflutter" 41 | minSdkVersion 16 42 | targetSdkVersion 28 43 | versionCode flutterVersionCode.toInteger() 44 | versionName flutterVersionName 45 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 46 | } 47 | 48 | signingConfigs { 49 | release { 50 | keyAlias keystoreProperties['keyAlias'] 51 | keyPassword keystoreProperties['keyPassword'] 52 | storeFile file(keystoreProperties['storeFile']) 53 | storePassword keystoreProperties['storePassword'] 54 | } 55 | } 56 | buildTypes { 57 | release { 58 | signingConfig signingConfigs.release 59 | } 60 | } 61 | 62 | } 63 | 64 | flutter { 65 | source '../..' 66 | } 67 | 68 | dependencies { 69 | testImplementation 'junit:junit:4.12' 70 | androidTestImplementation 'androidx.test:runner:1.1.0' 71 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' 72 | implementation 'androidx.palette:palette:1.0.0' 73 | 74 | // https://mvnrepository.com/artifact/com.github.bumptech.glide/glide 75 | implementation group: 'com.github.bumptech.glide', name: 'glide', version: '4.7.1' 76 | } 77 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 9 | 10 | 15 | 19 | 26 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/example/doubanmovieflutter/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.example.doubanmovieflutter; 2 | 3 | import android.os.Bundle; 4 | import io.flutter.app.FlutterActivity; 5 | import io.flutter.plugin.common.MethodCall; 6 | import io.flutter.plugin.common.MethodChannel; 7 | import io.flutter.plugins.GeneratedPluginRegistrant; 8 | 9 | public class MainActivity extends FlutterActivity { 10 | @Override 11 | protected void onCreate(Bundle savedInstanceState) { 12 | super.onCreate(savedInstanceState); 13 | GeneratedPluginRegistrant.registerWith(this); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | jcenter() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:3.4.1' 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 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jun 20 14:39:22 CST 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.1.1-all.zip 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /android/key.properties: -------------------------------------------------------------------------------- 1 | storePassword=7311531 2 | keyPassword=7311531 3 | keyAlias=key 4 | storeFile=/Users/zachary/code/flutter/sign/key.jks -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /doc/TOC.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 涉及知识点目录 4 | 5 | ###### 1.环境安装以及 Flutter 常用命令: 6 | 7 | ###### 2. UI 部分: 8 | 9 | - 基础控件用法 10 | - 复杂布局 11 | - "首页"中的卡片 12 | - "更多"页中的水平三等分布局 13 | - 嵌套滚动布局 14 | - "发现"页中多个水平和垂直滚动布局嵌套 15 | - 路由跳转 16 | - 自定义绘制控件 17 | - "五角星展示电影评分" 控件 18 | 19 | ###### 3.数据部分: 20 | 21 | - 网络请求 22 | - 实体类的编写和自动生成解析代码 23 | - 客户端中用爬虫思路爬取需要的数据 24 | 25 | ###### 4.Flutter 插件部分: 26 | 27 | - 在 Android Studio 和 Xcode 中集成原生的第三方库 28 | - 使用 Java/Kotlin 和 objective-c/Swift 编写桥接类 29 | 30 | ###### 5.打包部分: 31 | 32 | - Android 打包配置 33 | - ios 打包配置 34 | 35 | ------ 36 | 37 | 持续更新中 >>> 38 | 39 | -------------------------------------------------------------------------------- /images/flutter_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/flutter_logo.png -------------------------------------------------------------------------------- /images/icon_account_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_account_normal.png -------------------------------------------------------------------------------- /images/icon_account_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_account_selected.png -------------------------------------------------------------------------------- /images/icon_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_api.png -------------------------------------------------------------------------------- /images/icon_blog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_blog.png -------------------------------------------------------------------------------- /images/icon_explore_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_explore_normal.png -------------------------------------------------------------------------------- /images/icon_explore_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_explore_selected.png -------------------------------------------------------------------------------- /images/icon_github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_github.png -------------------------------------------------------------------------------- /images/icon_hot_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_hot_normal.png -------------------------------------------------------------------------------- /images/icon_hot_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_hot_selected.png -------------------------------------------------------------------------------- /images/icon_more_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_more_normal.png -------------------------------------------------------------------------------- /images/icon_more_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_more_selected.png -------------------------------------------------------------------------------- /images/icon_qq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_qq.png -------------------------------------------------------------------------------- /images/icon_ranking_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_ranking_down.png -------------------------------------------------------------------------------- /images/icon_ranking_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_ranking_up.png -------------------------------------------------------------------------------- /images/icon_upcoming_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_upcoming_normal.png -------------------------------------------------------------------------------- /images/icon_upcoming_selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_upcoming_selected.png -------------------------------------------------------------------------------- /images/icon_wx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/images/icon_wx.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /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 | def parse_KV_file(file, separator='=') 8 | file_abs_path = File.expand_path(file) 9 | if !File.exists? file_abs_path 10 | return []; 11 | end 12 | pods_ary = [] 13 | skip_line_start_symbols = ["#", "/"] 14 | File.foreach(file_abs_path) { |line| 15 | next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } 16 | plugin = line.split(pattern=separator) 17 | if plugin.length == 2 18 | podname = plugin[0].strip() 19 | path = plugin[1].strip() 20 | podpath = File.expand_path("#{path}", file_abs_path) 21 | pods_ary.push({:name => podname, :path => podpath}); 22 | else 23 | puts "Invalid plugin specification: #{line}" 24 | end 25 | } 26 | return pods_ary 27 | end 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | 32 | # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock 33 | # referring to absolute paths on developers' machines. 34 | system('rm -rf .symlinks') 35 | system('mkdir -p .symlinks/plugins') 36 | 37 | # Flutter Pods 38 | generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig') 39 | if generated_xcode_build_settings.empty? 40 | puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." 41 | end 42 | generated_xcode_build_settings.map { |p| 43 | if p[:name] == 'FLUTTER_FRAMEWORK_DIR' 44 | symlink = File.join('.symlinks', 'flutter') 45 | File.symlink(File.dirname(p[:path]), symlink) 46 | pod 'Flutter', :path => File.join(symlink, File.basename(p[:path])) 47 | end 48 | } 49 | 50 | # Plugin Pods 51 | plugin_pods = parse_KV_file('../.flutter-plugins') 52 | plugin_pods.map { |p| 53 | symlink = File.join('.symlinks', 'plugins', p[:name]) 54 | File.symlink(p[:path], symlink) 55 | pod p[:name], :path => File.join(symlink, 'ios') 56 | } 57 | end 58 | 59 | post_install do |installer| 60 | installer.pods_project.targets.each do |target| 61 | target.build_configurations.each do |config| 62 | config.build_settings['ENABLE_BITCODE'] = 'NO' 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Flutter (1.0.0) 3 | - flutter_webview_plugin (0.0.1): 4 | - Flutter 5 | - image_picker (0.0.1): 6 | - Flutter 7 | - shared_preferences (0.0.1): 8 | - Flutter 9 | 10 | DEPENDENCIES: 11 | - Flutter (from `.symlinks/flutter/ios`) 12 | - flutter_webview_plugin (from `.symlinks/plugins/flutter_webview_plugin/ios`) 13 | - image_picker (from `.symlinks/plugins/image_picker/ios`) 14 | - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) 15 | 16 | EXTERNAL SOURCES: 17 | Flutter: 18 | :path: ".symlinks/flutter/ios" 19 | flutter_webview_plugin: 20 | :path: ".symlinks/plugins/flutter_webview_plugin/ios" 21 | image_picker: 22 | :path: ".symlinks/plugins/image_picker/ios" 23 | shared_preferences: 24 | :path: ".symlinks/plugins/shared_preferences/ios" 25 | 26 | SPEC CHECKSUMS: 27 | Flutter: 9d0fac939486c9aba2809b7982dfdbb47a7b0296 28 | flutter_webview_plugin: 0b491d31c34ab5c86a71c9f1a57ac000dd1b75e9 29 | image_picker: ee00aab0487cedc80a304085219503cc6d0f2e22 30 | shared_preferences: 5a1d487c427ee18fcd3ea1f2a131569481834b53 31 | 32 | PODFILE CHECKSUM: 7765ea4305eaab0b3dfd384c7de11902aa3195fd 33 | 34 | COCOAPODS: 1.5.2 35 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | SwiftPalettePlugin.register(with:registrar(forPlugin: "channel:com.postmuseapp.designer/palette")) 12 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/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/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/ColorCutQuantizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorCutQuantizer.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | private let COMPONENT_RED = -3 13 | private let COMPONENT_GREEN = -2 14 | private let COMPONENT_BLUE = -1 15 | 16 | private let BLACK_MAX_LIGHTNESS = CGFloat(0.05) 17 | private let WHITE_MIN_LIGHTNESS = CGFloat(0.95) 18 | 19 | private typealias VboxPriorityQueue = PriorityQueue 20 | 21 | internal final class ColorCutQuantizer { 22 | 23 | fileprivate var colors = [Int64]() 24 | fileprivate var colorPopulations = [Int64: Int64]() 25 | 26 | /** list of quantized colors */ 27 | private(set) var quantizedColors = [PaletteSwatch]() 28 | 29 | /** 30 | Factory-method to generate a ColorCutQuantizer from a UIImage. 31 | 32 | :param: image Image to extract the pixel data from 33 | :param: maxColors The maximum number of colors that should be in the result palette. 34 | */ 35 | internal static func from(image: UIImage, maxColors: Int) -> ColorCutQuantizer { 36 | let pixels = image.pixels 37 | return ColorCutQuantizer(colorHistogram: ColorHistogram(pixels: pixels), maxColors: maxColors) 38 | } 39 | 40 | /** 41 | :param: colorHistogram histogram representing an image’s pixel data 42 | :param maxColors The maximum number of colors that should be in the result palette. 43 | */ 44 | private init(colorHistogram: ColorHistogram, maxColors: Int) { 45 | let rawColorCount = colorHistogram.numberOfColors 46 | let rawColors = colorHistogram.colors 47 | let rawColorCounts = colorHistogram.colorCounts 48 | 49 | // First, lets pack the populations into a SparseIntArray so that they can be easily 50 | // retrieved without knowing a color’s index 51 | self.colorPopulations = Dictionary(minimumCapacity: rawColorCount) 52 | for i in 0 ..< rawColors.count { 53 | self.colorPopulations[rawColors[i]] = rawColorCounts[i] 54 | } 55 | 56 | // Now go through all of the colors and keep those which we do not want to ignore 57 | var validColorCount = 0 58 | self.colors = [ ] 59 | self.colors.reserveCapacity(rawColorCount) 60 | 61 | for color in rawColors { 62 | guard !self.shouldIgnore(color: color) else { 63 | continue 64 | } 65 | 66 | self.colors.append(color) 67 | validColorCount += 1 68 | } 69 | 70 | if validColorCount <= maxColors { 71 | // The image has fewer colors than the maximum requested, so just return the colors 72 | self.quantizedColors = [ ] 73 | for color in self.colors { 74 | guard let populations = self.colorPopulations[color] else { 75 | continue 76 | } 77 | 78 | self.quantizedColors.append(PaletteSwatch(color: HexColor.toUIColor(color), population: populations)) 79 | } 80 | } else { 81 | // We need use quantization to reduce the number of colors 82 | self.quantizedColors = self.quantizePixels(maxColorIndex: validColorCount - 1, maxColors: maxColors) 83 | } 84 | } 85 | 86 | private func quantizePixels(maxColorIndex: Int, maxColors: Int) -> [PaletteSwatch] { 87 | // Create the priority queue which is sorted by volume descending. This means we always 88 | // split the largest box in the queue 89 | var pq = PriorityQueue(ascending: false, startingValues: Array()) 90 | 91 | // final PriorityQueue pq = PriorityQueue(maxColors, VBOX_COMPARATOR_VOLUME) 92 | 93 | // To start, offer a box which contains all of the colors 94 | pq.push(Vbox(quantizer: self, lowerIndex: 0, upperIndex: maxColorIndex)) 95 | 96 | // Now go through the boxes, splitting them until we have reached maxColors or there are no 97 | // more boxes to split 98 | self.splitBoxes(queue: &pq, maxSize: maxColors) 99 | 100 | // Finally, return the average colors of the color boxes 101 | return generateAverageColors(vboxes: pq) 102 | } 103 | 104 | /** 105 | Iterate through the Queue, popping Vbox objects from the queue and splitting them. Once 106 | split, the new box and the remaining box are offered back to the queue. 107 | 108 | :param: queue Priority queue to poll for boxes 109 | :param: maxSize Maximum amount of boxes to split 110 | */ 111 | private func splitBoxes(queue: inout VboxPriorityQueue, maxSize: Int) { 112 | while queue.count < maxSize { 113 | guard let vbox = queue.pop(), vbox.canSplit else { 114 | // If we get here then there are no more boxes to split, so return 115 | return 116 | } 117 | 118 | // First split the box, and offer the result 119 | queue.push(vbox.splitBox()) 120 | // Then offer the box back 121 | queue.push(vbox) 122 | } 123 | } 124 | 125 | private func generateAverageColors(vboxes: VboxPriorityQueue) -> [PaletteSwatch] { 126 | var colors = [PaletteSwatch]() 127 | 128 | for vbox in vboxes { 129 | let color = vbox.averageColor 130 | 131 | guard !type(of: self).shouldIgnore(color: color) else { 132 | continue 133 | } 134 | 135 | // As we’re averaging a color box, we can still get colors which we do not want, so 136 | // we check again here 137 | colors.append(color) 138 | } 139 | 140 | return colors 141 | } 142 | 143 | /** 144 | Modify the significant octet in a packed color int. Allows sorting based on the value of a 145 | single color component. 146 | */ 147 | private func modifySignificantOctet(dimension: Int, lowerIndex: Int, upperIndex: Int) { 148 | switch (dimension) { 149 | case COMPONENT_RED: 150 | // Already in RGB, no need to do anything 151 | break 152 | 153 | case COMPONENT_GREEN: 154 | // We need to do a RGB to GRB swap, or vice-versa 155 | for i in lowerIndex ... upperIndex { 156 | let color = self.colors[i] 157 | self.colors[i] = HexColor.fromRGB((color >> 8) & 0xFF, green: (color >> 16) & 0xFF, blue: color & 0xFF) 158 | } 159 | case COMPONENT_BLUE: 160 | // We need to do a RGB to BGR swap, or vice-versa 161 | for i in lowerIndex ... upperIndex { 162 | let color = self.colors[i] 163 | self.colors[i] = HexColor.fromRGB(color & 0xFF, green: (color >> 8) & 0xFF, blue: (color >> 16) & 0xFF) 164 | } 165 | default: 166 | break 167 | } 168 | } 169 | 170 | private func shouldIgnore(color: Int64) -> Bool { 171 | return HexColor.toHSL(color).shouldIgnore 172 | } 173 | 174 | private static func shouldIgnore(color: PaletteSwatch) -> Bool { 175 | return color.hsl.shouldIgnore 176 | } 177 | 178 | } 179 | 180 | extension HSLColor { 181 | 182 | fileprivate var shouldIgnore: Bool { 183 | return self.isWhite || self.isBlack || self.isNearRedILine 184 | } 185 | 186 | /** 187 | :return: true if the color represents a color which is close to black. 188 | */ 189 | fileprivate var isBlack: Bool { 190 | return self.lightness <= BLACK_MAX_LIGHTNESS 191 | } 192 | 193 | /** 194 | :return: true if the color represents a color which is close to white. 195 | */ 196 | fileprivate var isWhite: Bool { 197 | return self.lightness >= WHITE_MIN_LIGHTNESS 198 | } 199 | 200 | /** 201 | :return: true if the color lies close to the red side of the I line. 202 | */ 203 | fileprivate var isNearRedILine: Bool { 204 | return self.hue >= 10.0 && self.hue <= 37.0 && self.saturation <= 0.82 205 | } 206 | 207 | } 208 | 209 | /** Represents a tightly fitting box around a color space. */ 210 | private class Vbox: Hashable { 211 | // lower and upper index are inclusive 212 | private let lowerIndex: Int 213 | private var upperIndex: Int 214 | 215 | private var minRed = Int64(2) 216 | private var maxRed = Int64(2) 217 | 218 | private var minGreen = Int64(0) 219 | private var maxGreen = Int64(0) 220 | 221 | private var minBlue = Int64(2) 222 | private var maxBlue = Int64(2) 223 | 224 | private let quantizer: ColorCutQuantizer 225 | 226 | private static var ordinal = Int32(0) 227 | 228 | let hashValue = Int(OSAtomicIncrement32(&Vbox.ordinal)) 229 | 230 | init(quantizer: ColorCutQuantizer, lowerIndex: Int, upperIndex: Int) { 231 | self.quantizer = quantizer 232 | 233 | self.lowerIndex = lowerIndex 234 | self.upperIndex = upperIndex 235 | assert(self.lowerIndex <= self.upperIndex, "lowerIndex (\(self.lowerIndex)) can’t be > upperIndex (\(self.upperIndex))") 236 | self.fitBox() 237 | } 238 | 239 | var volume: Int64 { 240 | let red = Double((self.maxRed - self.minRed) + 1) 241 | let green = Double((self.maxGreen - self.minGreen) + 1) 242 | let blue = Double((self.maxBlue - self.minBlue) + 1) 243 | 244 | return Int64(red * green * blue) 245 | } 246 | 247 | var canSplit: Bool { 248 | return self.colorCount > 1 249 | } 250 | 251 | var colorCount: Int { 252 | return self.upperIndex - self.lowerIndex + 1 253 | } 254 | 255 | /** Recomputes the boundaries of this box to tightly fit the colors within the box. */ 256 | func fitBox() { 257 | // Reset the min and max to opposite values 258 | self.minRed = 0xFF 259 | self.minGreen = 0xFF 260 | self.minBlue = 0xFF 261 | 262 | self.maxRed = 0x0 263 | self.maxGreen = 0x0 264 | self.maxBlue = 0x0 265 | 266 | for i in self.lowerIndex ... self.upperIndex { 267 | let color = HexColor.toRGB(self.quantizer.colors[i]) 268 | 269 | self.maxRed = max(self.maxRed, color.red) 270 | self.minRed = min(self.minRed, color.red) 271 | 272 | self.maxGreen = max(self.maxGreen, color.green) 273 | self.minGreen = min(self.minGreen, color.green) 274 | 275 | self.maxBlue = max(self.maxBlue, color.blue) 276 | self.minBlue = min(self.minBlue, color.blue) 277 | } 278 | } 279 | 280 | /** 281 | Split this color box at the mid-point along it’s longest dimension 282 | 283 | :return: the new ColorBox 284 | */ 285 | func splitBox() -> Vbox { 286 | guard self.canSplit else { 287 | fatalError("Can not split a box with only 1 color") 288 | } 289 | 290 | // find median along the longest dimension 291 | let splitPoint = self.findSplitPoint() 292 | 293 | assert(splitPoint + 1 <= self.upperIndex, "splitPoint (\(splitPoint + 1)) can’t be > upperIndex (\(self.upperIndex)), lowerIndex: \(self.lowerIndex)") 294 | 295 | let newBox = Vbox(quantizer: self.quantizer, lowerIndex: splitPoint + 1, upperIndex: self.upperIndex) 296 | 297 | // Now change this box’s upperIndex and recompute the color boundaries 298 | self.upperIndex = splitPoint 299 | assert(self.lowerIndex <= self.upperIndex, "lowerIndex (\(self.lowerIndex)) can’t be > upperIndex (\(self.upperIndex))") 300 | self.fitBox() 301 | 302 | return newBox 303 | } 304 | 305 | /** 306 | :return: the dimension which this box is largest in 307 | */ 308 | var longestColorDimension: Int { 309 | let redLength = self.maxRed - self.minRed 310 | let greenLength = self.maxGreen - self.minGreen 311 | let blueLength = self.maxBlue - self.minBlue 312 | 313 | if redLength >= greenLength && redLength >= blueLength { 314 | return COMPONENT_RED 315 | } else if greenLength >= redLength && greenLength >= blueLength { 316 | return COMPONENT_GREEN 317 | } else { 318 | return COMPONENT_BLUE 319 | } 320 | } 321 | 322 | /** 323 | Finds the point within this box’s lowerIndex and upperIndex index of where to split. 324 | 325 | This is calculated by finding the longest color dimension, and then sorting the 326 | sub-array based on that dimension value in each color. The colors are then iterated over 327 | until a color is found with at least the midpoint of the whole box’s dimension midpoint. 328 | 329 | :return: the index of the colors array to split from 330 | */ 331 | func findSplitPoint() -> Int { 332 | let longestDimension = self.longestColorDimension 333 | 334 | // Sort the colors in this box based on the longest color dimension. 335 | 336 | var sorted = self.quantizer.colors[self.lowerIndex...self.upperIndex] 337 | sorted.sort() 338 | 339 | if longestDimension == COMPONENT_RED { 340 | sorted.sort() { HexColor.red($0) < HexColor.red($1) } 341 | } else if longestDimension == COMPONENT_GREEN { 342 | sorted.sort() { HexColor.green($0) < HexColor.green($1) } 343 | } else { 344 | sorted.sort() { HexColor.blue($0) < HexColor.blue($1) } 345 | } 346 | 347 | self.quantizer.colors[self.lowerIndex...self.upperIndex] = sorted 348 | 349 | let dimensionMidPoint = self.midPoint(longestDimension) 350 | 351 | for i in self.lowerIndex ..< self.upperIndex { 352 | let color = self.quantizer.colors[i] 353 | 354 | switch (longestDimension) { 355 | case COMPONENT_RED where HexColor.red(color) >= dimensionMidPoint: 356 | return i 357 | case COMPONENT_GREEN where HexColor.green(color) >= dimensionMidPoint: 358 | return i 359 | case COMPONENT_BLUE where HexColor.blue(color) > dimensionMidPoint: 360 | return i 361 | default: 362 | continue 363 | } 364 | } 365 | 366 | return self.lowerIndex 367 | } 368 | 369 | /** 370 | * @return the average color of this box. 371 | */ 372 | var averageColor: PaletteSwatch { 373 | var redSum = Int64(0) 374 | var greenSum = Int64(0) 375 | var blueSum = Int64(0) 376 | var totalPopulation = Int64(0) 377 | 378 | for i in self.lowerIndex ... self.upperIndex { 379 | let color = self.quantizer.colors[i] 380 | 381 | guard let colorPopulation = self.quantizer.colorPopulations[color] else { 382 | continue 383 | } 384 | 385 | totalPopulation += colorPopulation 386 | redSum += colorPopulation * HexColor.red(color) 387 | greenSum += colorPopulation * HexColor.green(color) 388 | blueSum += colorPopulation * HexColor.blue(color) 389 | } 390 | 391 | let redAverage = Int64(round(Double(redSum) / Double(totalPopulation))) 392 | let greenAverage = Int64(round(Double(greenSum) / Double(totalPopulation))) 393 | let blueAverage = Int64(round(Double(blueSum) / Double(totalPopulation))) 394 | 395 | return PaletteSwatch(rgbColor: RGBColor(red: redAverage, green: greenAverage, blue: blueAverage, alpha: 255), population: totalPopulation) 396 | } 397 | 398 | /** 399 | * @return the midpoint of this box in the given {@code dimension} 400 | */ 401 | func midPoint(_ dimension: Int) -> Int64 { 402 | switch (dimension) { 403 | case COMPONENT_GREEN: 404 | return (self.minGreen + self.maxGreen) / Int64(2) 405 | case COMPONENT_BLUE: 406 | return (self.minBlue + self.maxBlue) / Int64(2) 407 | case COMPONENT_RED: 408 | return (self.minRed + self.maxRed) / Int64(2) 409 | default: 410 | return (self.minRed + self.maxRed) / Int64(2) 411 | } 412 | } 413 | } 414 | 415 | extension Vbox: Comparable { } 416 | 417 | private func ==(lhs: Vbox, rhs: Vbox) -> Bool { 418 | return lhs.hashValue == rhs.hashValue 419 | } 420 | 421 | private func <=(lhs: Vbox, rhs: Vbox) -> Bool { 422 | return lhs.volume <= rhs.volume 423 | } 424 | 425 | private func >=(lhs: Vbox, rhs: Vbox) -> Bool { 426 | return lhs.volume >= rhs.volume 427 | } 428 | 429 | private func <(lhs: Vbox, rhs: Vbox) -> Bool { 430 | return lhs.volume < rhs.volume 431 | } 432 | 433 | private func >(lhs: Vbox, rhs: Vbox) -> Bool { 434 | return lhs.volume > rhs.volume 435 | } 436 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/ColorHistogram.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorHistogram.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /** 13 | Class which provides a histogram for RGB values. 14 | */ 15 | public struct ColorHistogram { 16 | 17 | /** 18 | An array containing all of the distinct colors in the image. 19 | */ 20 | private(set) public var colors = [Int64]() 21 | 22 | /** 23 | An array containing the frequency of a distinct colors within the image. 24 | */ 25 | private(set) public var colorCounts = [Int64]() 26 | 27 | /** 28 | Number of distinct colors in the image. 29 | */ 30 | private(set) public var numberOfColors: Int 31 | 32 | /** 33 | A new ColorHistogram instance. 34 | 35 | :param: Pixels array of image contents 36 | */ 37 | public init(pixels: [Int64]) { 38 | // Sort the pixels to enable counting below 39 | var pixels = pixels 40 | pixels.sort() 41 | 42 | // Count number of distinct colors 43 | self.numberOfColors = type(of: self).countDistinctColors(pixels) 44 | 45 | // Finally count the frequency of each color 46 | self.countFrequencies(pixels) 47 | } 48 | 49 | private static func countDistinctColors(_ pixels: [Int64]) -> Int { 50 | if pixels.count < 2 { 51 | // If we have less than 2 pixels we can stop here 52 | return pixels.count 53 | } 54 | 55 | // If we have at least 2 pixels, we have a minimum of 1 color... 56 | var colorCount = 1 57 | var currentColor = pixels[0] 58 | 59 | // Now iterate from the second pixel to the end, counting distinct colors 60 | for pixel in pixels { 61 | // If we encounter a new color, increase the population 62 | if pixel != currentColor { 63 | currentColor = pixel 64 | colorCount += 1 65 | } 66 | } 67 | 68 | return colorCount 69 | } 70 | 71 | private mutating func countFrequencies(_ pixels: [Int64]) { 72 | if pixels.count == 0 { 73 | return 74 | } 75 | 76 | var currentColorIndex = 0 77 | var currentColor = pixels[0] 78 | 79 | self.colors.append(currentColor) 80 | self.colorCounts.append(1) 81 | 82 | if pixels.count == 1 { 83 | // If we only have one pixel, we can stop here 84 | return 85 | } 86 | 87 | // Now iterate from the second pixel to the end, population distinct colors 88 | for pixel in pixels { 89 | if pixel == currentColor { 90 | // We’ve hit the same color as before, increase population 91 | self.colorCounts[currentColorIndex] += 1 92 | } else { 93 | // We’ve hit a new color, increase index 94 | currentColor = pixel 95 | 96 | currentColorIndex += 1 97 | self.colors.append(currentColor) 98 | self.colorCounts.append(1) 99 | } 100 | } 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/DefaultPaletteGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultGenerator.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | private let TARGET_DARK_LUMA = CGFloat(0.26) 13 | private let MAX_DARK_LUMA = CGFloat(0.45) 14 | 15 | private let MIN_LIGHT_LUMA = CGFloat(0.55) 16 | private let TARGET_LIGHT_LUMA = CGFloat(0.74) 17 | 18 | private let MIN_NORMAL_LUMA = CGFloat(0.3) 19 | private let TARGET_NORMAL_LUMA = CGFloat(0.5) 20 | private let MAX_NORMAL_LUMA = CGFloat(0.7) 21 | 22 | private let TARGET_MUTED_SATURATION = CGFloat(0.3) 23 | private let MAX_MUTED_SATURATION = CGFloat(0.4) 24 | 25 | private let TARGET_VIBRANT_SATURATION = CGFloat(1) 26 | private let MIN_VIBRANT_SATURATION = CGFloat(0.35) 27 | 28 | private let WEIGHT_SATURATION = CGFloat(3.0) 29 | private let WEIGHT_LUMA = CGFloat(6.0) 30 | private let WEIGHT_POPULATION = CGFloat(1.0) 31 | 32 | internal class DefaultPaletteGenerator: PaletteGenerator { 33 | 34 | private var swatches = [PaletteSwatch]() 35 | private var highestPopulation = Int64(0) 36 | 37 | private(set) var vibrantSwatch: PaletteSwatch? 38 | private(set) var lightVibrantSwatch: PaletteSwatch? 39 | private(set) var darkVibrantSwatch: PaletteSwatch? 40 | private(set) var mutedSwatch: PaletteSwatch? 41 | private(set) var lightMutedSwatch: PaletteSwatch? 42 | private(set) var darkMutedSwatch: PaletteSwatch? 43 | 44 | func generate(swatches: [PaletteSwatch]) { 45 | self.swatches = swatches 46 | 47 | self.highestPopulation = findMaxPopulation() 48 | 49 | self.generateVariationColors() 50 | 51 | // Now try and generate any missing colors 52 | self.generateEmptySwatches() 53 | } 54 | 55 | private func generateVariationColors() { 56 | self.vibrantSwatch = self.findColorVariation(targetLuma: TARGET_NORMAL_LUMA, minLuma: MIN_NORMAL_LUMA, maxLuma: MAX_NORMAL_LUMA, targetSaturation: TARGET_VIBRANT_SATURATION, minSaturation: MIN_VIBRANT_SATURATION, maxSaturation: 1.0) 57 | self.lightVibrantSwatch = self.findColorVariation(targetLuma: TARGET_LIGHT_LUMA, minLuma: MIN_LIGHT_LUMA, maxLuma: 1.0, targetSaturation: TARGET_VIBRANT_SATURATION, minSaturation: MIN_VIBRANT_SATURATION, maxSaturation: 1.0) 58 | self.darkVibrantSwatch = self.findColorVariation(targetLuma: TARGET_DARK_LUMA, minLuma: 0.0, maxLuma: MAX_DARK_LUMA, targetSaturation: TARGET_VIBRANT_SATURATION, minSaturation: MIN_VIBRANT_SATURATION, maxSaturation: 1.0) 59 | self.mutedSwatch = self.findColorVariation(targetLuma: TARGET_NORMAL_LUMA, minLuma: MIN_NORMAL_LUMA, maxLuma: MAX_NORMAL_LUMA, targetSaturation: TARGET_MUTED_SATURATION, minSaturation: 0.0, maxSaturation: MAX_MUTED_SATURATION) 60 | self.lightMutedSwatch = self.findColorVariation(targetLuma: TARGET_LIGHT_LUMA, minLuma: MIN_LIGHT_LUMA, maxLuma: 1.0, targetSaturation: TARGET_MUTED_SATURATION, minSaturation: 0.0, maxSaturation: MAX_MUTED_SATURATION) 61 | self.darkMutedSwatch = self.findColorVariation(targetLuma: TARGET_DARK_LUMA, minLuma: 0.0, maxLuma: MAX_DARK_LUMA, targetSaturation: TARGET_MUTED_SATURATION, minSaturation: 0.0, maxSaturation: MAX_MUTED_SATURATION) 62 | } 63 | 64 | /** Try and generate any missing swatches from the swatches we did find. */ 65 | private func generateEmptySwatches() { 66 | if self.vibrantSwatch == nil { 67 | // If we do not have a vibrant color... 68 | if let swatch = self.darkVibrantSwatch { 69 | // ...but we do have a dark vibrant, generate the value by modifying the luma 70 | self.vibrantSwatch = PaletteSwatch(color: swatch.hsl.colorWith(lightness: TARGET_NORMAL_LUMA), population: 0) 71 | } 72 | } 73 | 74 | if self.darkVibrantSwatch == nil { 75 | // If we do not have a dark vibrant color... 76 | if let swatch = self.vibrantSwatch { 77 | // ...but we do have a vibrant, generate the value by modifying the luma 78 | self.darkVibrantSwatch = PaletteSwatch(color: swatch.hsl.colorWith(lightness: TARGET_DARK_LUMA), population: 0) 79 | } 80 | } 81 | } 82 | 83 | /** Find the PaletteSwatch with the highest population value and return the population. */ 84 | private func findMaxPopulation() -> Int64 { 85 | var population = Int64(0) 86 | 87 | for swatch in self.swatches { 88 | population = max(population, swatch.population) 89 | } 90 | 91 | return population 92 | } 93 | 94 | private func findColorVariation(targetLuma: CGFloat, minLuma: CGFloat, maxLuma: CGFloat, targetSaturation: CGFloat, minSaturation: CGFloat, maxSaturation: CGFloat) -> PaletteSwatch? { 95 | var max: PaletteSwatch? = nil 96 | var maxValue = 0.0 97 | 98 | for swatch in self.swatches { 99 | let sat = swatch.hsl.saturation 100 | let luma = swatch.hsl.lightness 101 | 102 | guard sat >= minSaturation && sat <= maxSaturation && luma >= minLuma && luma <= maxLuma && !isAlreadySelected(swatch: swatch) else { 103 | continue 104 | } 105 | 106 | let value = Double(type(of: self).createComparisonValue(saturation: sat, targetSaturation: targetSaturation, luma: luma, targetLuma: targetLuma, population: swatch.population, maxPopulation: self.highestPopulation)) 107 | 108 | guard max == nil || value > maxValue else { 109 | continue 110 | } 111 | 112 | max = swatch 113 | maxValue = value 114 | } 115 | 116 | return max 117 | } 118 | 119 | /** 120 | :return: true if we have already selected PaletteSwatch 121 | */ 122 | private func isAlreadySelected(swatch: PaletteSwatch) -> Bool { 123 | return self.vibrantSwatch == swatch || self.darkVibrantSwatch == swatch || self.lightVibrantSwatch == swatch || self.mutedSwatch == swatch || self.darkMutedSwatch == swatch || self.lightMutedSwatch == swatch 124 | } 125 | 126 | private static func createComparisonValue(saturation: CGFloat, targetSaturation: CGFloat, luma: CGFloat, targetLuma: CGFloat, population: Int64, maxPopulation: Int64) -> CGFloat { 127 | return self.createComparisonValue(saturation: saturation, targetSaturation: targetSaturation, saturationWeight: WEIGHT_SATURATION, luma: luma, targetLuma: targetLuma, lumaWeight: WEIGHT_LUMA, population: population, maxPopulation: maxPopulation, populationWeight: WEIGHT_POPULATION) 128 | } 129 | 130 | private static func createComparisonValue(saturation: CGFloat, targetSaturation: CGFloat, saturationWeight: CGFloat, luma: CGFloat, targetLuma: CGFloat, lumaWeight: CGFloat, population: Int64, maxPopulation: Int64, populationWeight: CGFloat) -> CGFloat { 131 | return weightedMean(values: [ 132 | invertDiff(value: saturation, targetValue: targetSaturation), saturationWeight, 133 | invertDiff(value: luma, targetValue: targetLuma), lumaWeight, 134 | CGFloat(population) / CGFloat(maxPopulation), populationWeight 135 | ]) 136 | } 137 | 138 | /** 139 | Returns a value in the range 0-1. 1 is returned when value equals the targetValue and then decreases as the 140 | absolute difference between value and {@code targetValue} increases. 141 | 142 | :param: value the item’s value 143 | :param: targetValue the value which we desire 144 | */ 145 | private static func invertDiff(value: CGFloat, targetValue: CGFloat) -> CGFloat { 146 | return 1.0 - abs(value - targetValue) 147 | } 148 | 149 | private static func weightedMean(values: [CGFloat]) -> CGFloat { 150 | var sum = CGFloat(0) 151 | var sumWeight = CGFloat(0) 152 | 153 | for i in stride(from: 0, to: values.count - 1, by: 2) { 154 | let value = values[i] 155 | let weight = values[i + 1] 156 | 157 | sum += (value * weight) 158 | sumWeight += weight 159 | } 160 | 161 | return sum / sumWeight 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/HSLColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HSL.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | internal struct HSLColor { 13 | let hue: CGFloat 14 | let saturation: CGFloat 15 | let lightness: CGFloat 16 | let alpha: CGFloat 17 | 18 | init(hue: CGFloat, saturation: CGFloat, lightness: CGFloat, alpha: CGFloat) { 19 | self.hue = hue 20 | self.saturation = saturation 21 | self.lightness = lightness 22 | self.alpha = alpha 23 | } 24 | 25 | var color: UIColor { 26 | return self.rgb.color 27 | } 28 | 29 | var rgb: RGBColor { 30 | let c = (1.0 - abs(2 * self.lightness - 1.0)) * self.saturation 31 | let m = self.lightness - 0.5 * c 32 | let x = c * (1.0 - abs(((self.hue / 60.0).truncatingRemainder(dividingBy: 2.0)) - 1.0)) 33 | 34 | let hueSegment = Int(self.hue / 60.0) 35 | 36 | var r = Int64(0) 37 | var g = Int64(0) 38 | var b = Int64(0) 39 | 40 | switch hueSegment { 41 | case 0: 42 | r = Int64(round(255 * (c + m))) 43 | g = Int64(round(255 * (x + m))) 44 | b = Int64(round(255 * m)) 45 | case 1: 46 | r = Int64(round(255 * (x + m))) 47 | g = Int64(round(255 * (c + m))) 48 | b = Int64(round(255 * m)) 49 | case 2: 50 | r = Int64(round(255 * m)) 51 | g = Int64(round(255 * (c + m))) 52 | b = Int64(round(255 * (x + m))) 53 | case 3: 54 | r = Int64(round(255 * m)) 55 | g = Int64(round(255 * (x + m))) 56 | b = Int64(round(255 * (c + m))) 57 | case 4: 58 | r = Int64(round(255 * (x + m))) 59 | g = Int64(round(255 * m)) 60 | b = Int64(round(255 * (c + m))) 61 | case 5, 6: 62 | r = Int64(round(255 * (c + m))) 63 | g = Int64(round(255 * m)) 64 | b = Int64(round(255 * (x + m))) 65 | default: 66 | break 67 | } 68 | 69 | r = max(0, min(255, r)) 70 | g = max(0, min(255, g)) 71 | b = max(0, min(255, b)) 72 | 73 | return RGBColor(red: r, green: g, blue: b, alpha: Int64(round(self.alpha * 255))) 74 | } 75 | 76 | internal func colorWith(lightness: CGFloat) -> HSLColor { 77 | return HSLColor(hue: self.hue, saturation: self.saturation, lightness: self.lightness, alpha: self.alpha) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/HexColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HexColor.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import UIKit 10 | 11 | private let MIN_ALPHA_SEARCH_MAX_ITERATIONS = Int64(10) 12 | private let MIN_ALPHA_SEARCH_PRECISION = Int64(10) 13 | 14 | internal struct HexColor { 15 | static let WHITE = HexColor.fromRGB(255, green: 255, blue: 255) 16 | static let BLACK = HexColor.fromRGB(0, green: 0, blue: 0) 17 | 18 | /** 19 | :return: The alpha component of a color int. 20 | */ 21 | internal static func alpha(_ color: Int64) -> Int64 { 22 | return color >> 24 23 | } 24 | 25 | /** 26 | :return: The red component of a color int. 27 | */ 28 | internal static func red(_ color: Int64) -> Int64 { 29 | return (color >> 16) & 0xFF 30 | } 31 | 32 | /** 33 | :return: The green component of a color int. 34 | */ 35 | internal static func green(_ color: Int64) -> Int64 { 36 | return (color >> 8) & 0xFF 37 | } 38 | 39 | /** 40 | :return: The blue component of a color int. 41 | */ 42 | internal static func blue(_ color: Int64) -> Int64 { 43 | return color & 0xFF 44 | } 45 | 46 | /** 47 | Return a color-int from red, green, blue components. 48 | The alpha component is implicity 255 (fully opaque). 49 | These component values should be [0..255], but there is no 50 | range check performed, so if they are out of range, the 51 | returned color is undefined. 52 | 53 | :param: red Red component [0..255] of the color 54 | :param: green Green component [0..255] of the color 55 | :param: blue Blue component [0..255] of the color 56 | */ 57 | internal static func fromRGB(_ red: Int64, green: Int64, blue: Int64) -> Int64 { 58 | return (0xFF << 24) | (red << 16) | (green << 8) | blue 59 | } 60 | 61 | /** 62 | Return a color-int from alpha, red, green, blue components. 63 | These component values should be [0..255], but there is no 64 | range check performed, so if they are out of range, the 65 | returned color is undefined. 66 | 67 | :param: alpha Alpha component [0..255] of the color 68 | :param: red Red component [0..255] of the color 69 | :param: green Green component [0..255] of the color 70 | :param: blue Blue component [0..255] of the color 71 | */ 72 | internal static func fromARGB(_ alpha: Int64, red: Int64, green: Int64, blue: Int64) -> Int64 { 73 | return (alpha << 24) | (red << 16) | (green << 8) | blue 74 | } 75 | 76 | internal static func toUIColor(_ color: Int64) -> UIColor { 77 | return UIColor(red: CGFloat(self.red(color)) / 255.0, green: CGFloat(self.green(color)) / 255.0, blue: CGFloat(self.blue(color)) / 255.0, alpha: CGFloat(self.alpha(color)) / 255.0) 78 | } 79 | 80 | internal static func toRGB(_ color: Int64) -> RGBColor { 81 | return RGBColor(red: self.red(color), green: self.green(color), blue: self.blue(color), alpha: self.alpha(color)) 82 | } 83 | 84 | internal static func toHSL(_ color: Int64) -> HSLColor { 85 | return self.toRGB(color).hsl 86 | } 87 | 88 | /** Composite two potentially translucent colors over each other and returns the result. */ 89 | internal static func compositeColors(_ foreground: Int64, background: Int64) -> Int64 { 90 | let alpha1 = CGFloat(self.alpha(foreground)) / 255.0 91 | let alpha2 = CGFloat(self.alpha(background)) / 255.0 92 | 93 | let a = (alpha1 + alpha2) * (1.0 - alpha1) 94 | let r = (CGFloat(self.red(foreground)) * alpha1) + (CGFloat(self.red(background)) * alpha2 * (1.0 - alpha1)) 95 | let g = (CGFloat(self.green(foreground)) * alpha1) + (CGFloat(self.green(background)) * alpha2 * (1.0 - alpha1)) 96 | let b = (CGFloat(self.blue(foreground)) * alpha1) + (CGFloat(self.blue(background)) * alpha2 * (1.0 - alpha1)) 97 | 98 | return self.fromARGB(Int64(a), red: Int64(r), green: Int64(g), blue: Int64(b)) 99 | } 100 | 101 | /** 102 | Formula defined here: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef 103 | 104 | :return: The luminance of a color 105 | */ 106 | internal static func calculateLuminance(color: Int64) -> CGFloat { 107 | var red = CGFloat(self.red(color)) / 255.0 108 | red = red < 0.03928 ? red / 12.92 : pow((red + 0.055) / 1.055, 2.4) 109 | 110 | var green = CGFloat(self.green(color)) / 255.0 111 | green = green < 0.03928 ? green / 12.92 : pow((green + 0.055) / 1.055, 2.4) 112 | 113 | var blue = CGFloat(self.blue(color)) / 255.0 114 | blue = blue < 0.03928 ? blue / 12.92 : pow((blue + 0.055) / 1.055, 2.4) 115 | 116 | return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue) 117 | } 118 | 119 | /** 120 | Returns the contrast ratio between foreground and background. 121 | background must be opaque. 122 | 123 | Formula defined http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef 124 | */ 125 | internal static func calculateContrast(foreground: Int64, background: Int64) -> CGFloat { 126 | var foreground = foreground 127 | 128 | assert(self.alpha(background) == 255, "background can not be translucent") 129 | 130 | if self.alpha(foreground) < 255 { 131 | // If the foreground is translucent, composite the foreground over the background 132 | foreground = self.compositeColors(foreground, background: background) 133 | } 134 | 135 | let luminance1 = calculateLuminance(color: foreground) + 0.05 136 | let luminance2 = calculateLuminance(color: background) + 0.05 137 | 138 | // Now return the lighter luminance divided by the darker luminance 139 | return max(luminance1, luminance2) / min(luminance1, luminance2) 140 | } 141 | 142 | /** 143 | Calculates the minimum alpha value which can be applied to foreground so that would 144 | have a contrast value of at least minContrastRatio when compared to 145 | background. 146 | 147 | :param: foreground the foreground color. 148 | :param: background the background color. Should be opaque. 149 | :param: minContrastRatio the minimum contrast ratio. 150 | 151 | :return: the alpha value in the range 0-255, or nil if no value could be calculated. 152 | */ 153 | internal static func calculateMinimumAlpha(foreground: Int64, background: Int64, minContrastRatio: CGFloat) -> Int64? { 154 | assert(self.alpha(background) == 255, "background can not be translucent") 155 | 156 | // First lets check that a fully opaque foreground has sufficient contrast 157 | var testForeground = self.setAlphaComponent(color: foreground, alpha: 255) 158 | var testRatio: CGFloat = self.calculateContrast(foreground: testForeground, background: background) 159 | if testRatio < minContrastRatio { 160 | // Fully opaque foreground does not have sufficient contrast, return error 161 | return nil 162 | } 163 | 164 | // Binary search to find a value with the minimum value which provides sufficient contrast 165 | var numIterations = Int64(0) 166 | var minAlpha = Int64(0) 167 | var maxAlpha = Int64(255) 168 | 169 | while numIterations <= MIN_ALPHA_SEARCH_MAX_ITERATIONS && (maxAlpha - minAlpha) > MIN_ALPHA_SEARCH_PRECISION { 170 | let testAlpha = Int64((minAlpha + maxAlpha) / 2) 171 | 172 | testForeground = self.setAlphaComponent(color: foreground, alpha: testAlpha) 173 | testRatio = self.calculateContrast(foreground: testForeground, background: background) 174 | 175 | if testRatio < minContrastRatio { 176 | minAlpha = testAlpha 177 | } else { 178 | maxAlpha = testAlpha 179 | } 180 | 181 | numIterations += 1 182 | } 183 | 184 | // Conservatively return the max of the range of possible alphas, which is known to pass. 185 | return maxAlpha 186 | } 187 | 188 | /** Set the alpha component of color to be alpha. */ 189 | internal static func setAlphaComponent(color: Int64, alpha: Int64) -> Int64 { 190 | assert(alpha >= 0 && alpha <= 255, "alpha must be between 0 and 255.") 191 | return (color & 0x00ffffff) | (alpha << 24) 192 | } 193 | 194 | } 195 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/Palette.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Palette.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | private func createQueue() -> DispatchQueue { 13 | return DispatchQueue(label: "palette.generator", qos: .background, attributes: .concurrent, target: DispatchQueue.global(qos: .background)) 14 | } 15 | 16 | public struct Palette { 17 | static var internalQueue = createQueue() 18 | 19 | /** All of the swatches which make up the palette */ 20 | public let swatches: [PaletteSwatch] 21 | private let generator: PaletteGenerator 22 | 23 | private init(swatches: [PaletteSwatch], generator: PaletteGenerator) { 24 | self.swatches = swatches 25 | self.generator = generator 26 | } 27 | 28 | /** 29 | Create a palette synchronously. 30 | */ 31 | public init(configuration: PaletteConfiguration) { 32 | let (swatches, generator) = configuration.generate() 33 | self.init(swatches: swatches, generator: generator) 34 | } 35 | 36 | public static func generateWith(configuration: PaletteConfiguration, completion: @escaping (Palette) -> Void) { 37 | self.generateWith(configuration: configuration, queue: self.internalQueue, completion: completion) 38 | } 39 | 40 | public static func generateWith(configuration: PaletteConfiguration, queue: DispatchQueue, completion: @escaping (Palette) -> Void) { 41 | queue.async { 42 | let (swatches, generator) = configuration.generate() 43 | let palette = self.init(swatches: swatches, generator: generator) 44 | 45 | DispatchQueue.main.async { 46 | completion(palette) 47 | } 48 | } 49 | } 50 | 51 | /** The most vibrant swatch in the palette. */ 52 | public var vibrantSwatch: PaletteSwatch? { 53 | return self.generator.vibrantSwatch 54 | } 55 | 56 | /** Light and vibrant swatch from the palette. */ 57 | public var lightVibrantSwatch: PaletteSwatch? { 58 | return self.generator.lightVibrantSwatch 59 | } 60 | 61 | /** Dark and vibrant swatch from the palette. */ 62 | public var darkVibrantSwatch: PaletteSwatch? { 63 | return self.generator.darkVibrantSwatch 64 | } 65 | 66 | /** Muted swatch from the palette. */ 67 | public var mutedSwatch: PaletteSwatch? { 68 | return self.generator.mutedSwatch 69 | } 70 | 71 | /** Muted and light swatch from the palette. */ 72 | public var lightMutedSwatch: PaletteSwatch? { 73 | return self.generator.lightMutedSwatch 74 | } 75 | 76 | /** Muted and dark swatch from the palette. */ 77 | public var darkMutedSwatch: PaletteSwatch? { 78 | return self.generator.darkMutedSwatch 79 | } 80 | 81 | /** 82 | Returns the most vibrant color in the palette as an RGB packed int. 83 | 84 | :param: defaultColor value to return if the swatch isn't available 85 | */ 86 | public func vibrantColor(defaultColor: UIColor) -> UIColor { 87 | return self.vibrantSwatch?.color ?? defaultColor 88 | } 89 | 90 | /** 91 | Returns a light and vibrant color from the palette as an RGB packed int. 92 | 93 | :param: defaultColor value to return if the swatch isn't available 94 | */ 95 | public func lightVibrantColor(defaultColor: UIColor) -> UIColor { 96 | return self.lightVibrantSwatch?.color ?? defaultColor 97 | } 98 | 99 | /** 100 | Returns a dark and vibrant color from the palette as an RGB packed int. 101 | 102 | :param: defaultColor value to return if the swatch isn't available 103 | */ 104 | public func darkVibrantColor(defaultColor: UIColor) -> UIColor { 105 | return self.darkVibrantSwatch?.color ?? defaultColor 106 | } 107 | 108 | /** 109 | Returns a muted color from the palette as an RGB packed int. 110 | 111 | :param: defaultColor value to return if the swatch isn't available 112 | */ 113 | public func mutedColor(defaultColor: UIColor) -> UIColor { 114 | return self.mutedSwatch?.color ?? defaultColor 115 | } 116 | 117 | /** 118 | Returns a muted and light color from the palette as an RGB packed int. 119 | 120 | :param: defaultColor value to return if the swatch isn't available 121 | */ 122 | public func lightMutedColor(defaultColor: UIColor) -> UIColor { 123 | return self.lightMutedSwatch?.color ?? defaultColor 124 | } 125 | 126 | /** 127 | Returns a muted and dark color from the palette as an RGB packed int. 128 | 129 | :param: defaultColor value to return if the swatch isn't available 130 | */ 131 | public func darkMutedColor(defaultColor: UIColor) -> UIColor { 132 | return self.darkMutedSwatch?.color ?? defaultColor 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/PaletteConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaletteConfiguration.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | public struct PaletteConfiguration { 13 | private static let DEFAULT_CALCULATE_NUMBER_COLORS = 16 14 | private static let DEFAULT_RESIZE_BITMAP_MAX_DIMENSION = CGFloat(192) 15 | 16 | private let bitmap: UIImage? 17 | private let swatches: [PaletteSwatch]? 18 | 19 | /** 20 | The maximum number of colors to use in the quantization step when using an image 21 | as the source. 22 | 23 | Good values for depend on the source image type. For landscapes, good values are in 24 | the range 10-16. For images which are largely made up of people’s faces then this 25 | value should be increased to ~24. 26 | */ 27 | public var maxColors = DEFAULT_CALCULATE_NUMBER_COLORS 28 | 29 | /** 30 | Set the resize value when using an image as the source. If the bitmap’s largest 31 | dimension is greater than the value specified, then the image will be resized so 32 | that it’s largest dimension matches maxDimension. If the bitmap is smaller or 33 | equal, the original is used as-is. 34 | 35 | This value has a large effect on the processing time. The larger the resized image is, 36 | the greater time it will take to generate the palette. The smaller the image is, the 37 | more detail is lost in the resulting image and thus less precision for color selection. 38 | */ 39 | public var resizeMaxDimension = DEFAULT_RESIZE_BITMAP_MAX_DIMENSION 40 | 41 | /** Generator to use when generating the palette. If nil, the default generator will be used. */ 42 | public var generator: PaletteGenerator? 43 | 44 | /** 45 | Create configuration using a source image 46 | */ 47 | public init(image: UIImage) { 48 | self.bitmap = image 49 | self.swatches = nil 50 | } 51 | 52 | /** 53 | Create configuration using an array of PaletteSwatch instances. 54 | Typically only used for testing. 55 | */ 56 | public init(swatches: [PaletteSwatch]) { 57 | assert(swatches.count > 0, "Swatches array must contain at least one swatch") 58 | 59 | self.bitmap = nil 60 | self.swatches = swatches 61 | } 62 | 63 | internal func generate() -> ([PaletteSwatch], PaletteGenerator) { 64 | var swatches: [PaletteSwatch] 65 | 66 | if let image = self.bitmap { 67 | // We have a Image so we need to quantization to reduce the number of colors 68 | 69 | assert(self.resizeMaxDimension > 0, "Minimum dimension size for resizing should should be >= 1") 70 | 71 | // First we’ll scale down the bitmap so it’s largest dimension is as specified 72 | guard let scaledBitmap = image.scaleDown(to: self.resizeMaxDimension) else { 73 | fatalError("Unable to scale down image.") 74 | } 75 | 76 | // Now generate a quantizer from the Bitmap 77 | let quantizer = ColorCutQuantizer.from(image: scaledBitmap, maxColors: self.maxColors); 78 | swatches = quantizer.quantizedColors 79 | } else if let s = self.swatches { 80 | // We’re using the provided swatches 81 | swatches = s 82 | } else { 83 | fatalError("Invalid palette configuration.") 84 | } 85 | 86 | // If we haven't been provided with a generator, use the default 87 | let generator: PaletteGenerator 88 | 89 | if let g = self.generator { 90 | generator = g 91 | } else { 92 | generator = DefaultPaletteGenerator() 93 | } 94 | 95 | // Now call let the Generator do it’s thing 96 | generator.generate(swatches: swatches) 97 | 98 | return (swatches, generator) 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/PaletteGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaletteGenerator.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | /** 10 | Protocol for Palette which allows custom processing of the list of 11 | PaletteSwatch instances which represent an image. 12 | 13 | You should do as much processing as possible during the 14 | generate method call. The other methods in this class 15 | may be called multiple times to retrieve an appropriate PaletteSwatch. 16 | */ 17 | public protocol PaletteGenerator { 18 | 19 | /** 20 | This method will be called with the PaletteSwatch that represent an image. 21 | You should process this list so that you have appropriate values when the other methods in 22 | class are called. 23 | 24 | This method will probably be called on a background thread. 25 | */ 26 | func generate(swatches: [PaletteSwatch]) 27 | 28 | /** Return the most vibrant PaletteSwatch */ 29 | var vibrantSwatch: PaletteSwatch? { get } 30 | 31 | /** A light and vibrant PaletteSwatch */ 32 | var lightVibrantSwatch: PaletteSwatch? { get } 33 | 34 | /** A dark and vibrant PaletteSwatch */ 35 | var darkVibrantSwatch: PaletteSwatch? { get } 36 | 37 | /** A muted PaletteSwatch */ 38 | var mutedSwatch: PaletteSwatch? { get } 39 | 40 | /** A muted and light PaletteSwatch */ 41 | var lightMutedSwatch: PaletteSwatch? { get } 42 | 43 | /** A muted and dark PaletteSwatch */ 44 | var darkMutedSwatch: PaletteSwatch? { get } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/PaletteSwatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaletteSwatch.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /** Represents a color swatch generated from an image’s palette. */ 13 | open class PaletteSwatch { 14 | private static let MIN_CONTRAST_TITLE_TEXT = CGFloat(3.0) 15 | private static let MIN_CONTRAST_BODY_TEXT = CGFloat(4.5) 16 | 17 | fileprivate let rgb: RGBColor 18 | private let hex: Int64 19 | 20 | /** This swatch’s color */ 21 | public let color: UIColor 22 | 23 | /** The number of pixels represented by this swatch */ 24 | public let population: Int64 25 | 26 | private var generatedTextColors: Bool = false 27 | private var _titleTextColor: UIColor? 28 | private var _bodyTextColor: UIColor? 29 | 30 | fileprivate var _hsl: HSLColor? 31 | 32 | public convenience init(color: UIColor, population: Int64) { 33 | self.init(rgbColor: RGBColor(color: color), population: population) 34 | } 35 | 36 | internal convenience init(color: HSLColor, population: Int64) { 37 | self.init(rgbColor: color.rgb, population: population) 38 | } 39 | 40 | internal init(rgbColor: RGBColor, population: Int64) { 41 | self.rgb = rgbColor 42 | self.hex = rgbColor.hex 43 | self.color = rgbColor.color 44 | self.population = population 45 | } 46 | 47 | /** 48 | Return this swatch’s HSL values. 49 | */ 50 | internal var hsl: HSLColor { 51 | if let hsl = self._hsl { 52 | return hsl 53 | } 54 | 55 | let hsl = self.rgb.hsl 56 | self._hsl = hsl 57 | return hsl 58 | } 59 | 60 | /** 61 | * An appropriate color to use for any 'title' text which is displayed over this 62 | * Swatch’s color. This color is guaranteed to have sufficient contrast. 63 | */ 64 | open var titleTextColor: UIColor? { 65 | self.ensureTextColorsGenerated() 66 | return self._titleTextColor 67 | } 68 | 69 | /** 70 | * An appropriate color to use for any 'body' text which is displayed over this 71 | * Swatch’s color. This color is guaranteed to have sufficient contrast. 72 | */ 73 | open var bodyTextColor: UIColor? { 74 | self.ensureTextColorsGenerated() 75 | return self._bodyTextColor 76 | } 77 | 78 | private func ensureTextColorsGenerated() { 79 | guard !self.generatedTextColors else { 80 | return 81 | } 82 | 83 | // First check white, as most colors will be dark 84 | let lightBodyAlpha = HexColor.calculateMinimumAlpha(foreground: HexColor.WHITE, background: hex, minContrastRatio: type(of: self).MIN_CONTRAST_BODY_TEXT) 85 | let lightTitleAlpha = HexColor.calculateMinimumAlpha(foreground: HexColor.WHITE, background: hex, minContrastRatio: type(of: self).MIN_CONTRAST_TITLE_TEXT) 86 | 87 | if let lightBodyAlpha = lightBodyAlpha, let lightTitleAlpha = lightTitleAlpha { 88 | // If we found valid light values, use them and return 89 | self._bodyTextColor = UIColor(white: 1.0, alpha: CGFloat(lightBodyAlpha) / 255.0) 90 | self._titleTextColor = UIColor(white: 1.0, alpha: CGFloat(lightTitleAlpha) / 255.0) 91 | 92 | self.generatedTextColors = true 93 | return 94 | } 95 | 96 | let darkBodyAlpha = HexColor.calculateMinimumAlpha(foreground: HexColor.BLACK, background: hex, minContrastRatio: type(of: self).MIN_CONTRAST_BODY_TEXT) 97 | let darkTitleAlpha = HexColor.calculateMinimumAlpha(foreground: HexColor.BLACK, background: hex, minContrastRatio: type(of: self).MIN_CONTRAST_TITLE_TEXT) 98 | 99 | if let darkBodyAlpha = darkBodyAlpha, let darkTitleAlpha = darkTitleAlpha { 100 | // If we found valid dark values, use them and return 101 | self._bodyTextColor = UIColor(white: 0.0, alpha: CGFloat(darkBodyAlpha) / 255.0) 102 | self._titleTextColor = UIColor(white: 0.0, alpha: CGFloat(darkTitleAlpha) / 255.0) 103 | 104 | self.generatedTextColors = true 105 | return 106 | } 107 | 108 | // If we reach here then we can not find title and body values which use the same 109 | // lightness, we need to use mismatched values 110 | if let lightBodyAlpha = lightBodyAlpha { 111 | self._bodyTextColor = UIColor(white: 1.0, alpha: CGFloat(lightBodyAlpha) / 255.0) 112 | } else if let darkBodyAlpha = darkBodyAlpha { 113 | self._bodyTextColor = UIColor(white: 0.0, alpha: CGFloat(darkBodyAlpha) / 255.0) 114 | } 115 | 116 | if let lightTitleAlpha = lightTitleAlpha { 117 | self._titleTextColor = UIColor(white: 1.0, alpha: CGFloat(lightTitleAlpha) / 255.0) 118 | } else if let darkTitleAlpha = darkTitleAlpha { 119 | self._titleTextColor = UIColor(white: 0.0, alpha: CGFloat(darkTitleAlpha) / 255.0) 120 | } 121 | 122 | self.generatedTextColors = true 123 | } 124 | 125 | } 126 | 127 | extension PaletteSwatch: CustomDebugStringConvertible { 128 | 129 | public var debugDescription: String { 130 | var description = "<\(type(of: self)) 0x\(self.hashValue)" 131 | description += "; color = \(self.color)" 132 | description += "; hsl = \(String(describing: self._hsl))" 133 | description += "; population = \(self.population)" 134 | description += "; titleTextColor = \(String(describing: self.titleTextColor))" 135 | description += "; bodyTextColor = \(String(describing: self.bodyTextColor))" 136 | return description + ">" 137 | } 138 | 139 | } 140 | 141 | extension PaletteSwatch: Equatable, Hashable { 142 | 143 | public var hashValue: Int { 144 | let maxInt = Int(Int32.max) 145 | return Int((31 * self.color.hashValue + Int(self.population)) % maxInt) 146 | } 147 | 148 | } 149 | 150 | public func ==(lhs: PaletteSwatch, rhs: PaletteSwatch) -> Bool { 151 | return lhs.population == rhs.population && lhs.rgb == rhs.rgb 152 | } 153 | 154 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/RGBColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RGB.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | internal struct RGBColor: Hashable, Equatable { 13 | let red: Int64 14 | let green: Int64 15 | let blue: Int64 16 | let alpha: Int64 17 | let hashValue: Int 18 | 19 | init(red: Int64, green: Int64, blue: Int64, alpha: Int64) { 20 | self.red = red 21 | self.green = green 22 | self.blue = blue 23 | self.alpha = alpha 24 | let maxInt = Int64(Int32.max) 25 | self.hashValue = Int(((alpha << 24) | (red << 16) | (green << 8) | blue) % maxInt) 26 | } 27 | 28 | init(color: UIColor) { 29 | var red: CGFloat = 0.0 30 | var green: CGFloat = 0.0 31 | var blue: CGFloat = 0.0 32 | var alpha: CGFloat = 0.0 33 | 34 | color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 35 | 36 | self.init(red: Int64(round(red * 255.0)), green: Int64(round(green * 255.0)), blue: Int64(round(blue * 255.0)), alpha: Int64(round(alpha * 255.0))) 37 | } 38 | 39 | var color: UIColor { 40 | return UIColor(red: CGFloat(self.red) / 255.0, green: CGFloat(self.green) / 255.0, blue: CGFloat(self.blue) / 255.0, alpha: CGFloat(self.alpha) / 255.0) 41 | } 42 | 43 | var hex: Int64 { 44 | return HexColor.fromRGB(self.red, green: self.green, blue: self.blue) 45 | } 46 | 47 | var hsl: HSLColor { 48 | let rf = CGFloat(self.red) / 255.0 49 | let gf = CGFloat(self.green) / 255.0 50 | let bf = CGFloat(self.blue) / 255.0 51 | 52 | let maxValue = max(rf, max(gf, bf)) 53 | let minValue = min(rf, min(gf, bf)) 54 | let deltaMaxMin = maxValue - minValue 55 | 56 | var h: CGFloat 57 | var s: CGFloat 58 | let l = (maxValue + minValue) / 2.0 59 | 60 | if maxValue == minValue { 61 | // Monochromatic 62 | h = 0.0 63 | s = 0.0 64 | } else { 65 | if maxValue == rf { 66 | h = ((gf - bf) / deltaMaxMin).truncatingRemainder(dividingBy: 6.0) 67 | } else if maxValue == gf { 68 | h = ((bf - rf) / deltaMaxMin) + 2.0 69 | } else { 70 | h = ((rf - gf) / deltaMaxMin) + 4.0 71 | } 72 | 73 | s = deltaMaxMin / (1.0 - abs(2.0 * l - 1.0)) 74 | } 75 | 76 | return HSLColor(hue: (h * 60.0).truncatingRemainder(dividingBy: 360.0), saturation: s, lightness: l, alpha: CGFloat(self.alpha) / 255.0) 77 | } 78 | 79 | } 80 | 81 | internal func ==(lhs: RGBColor, rhs: RGBColor) -> Bool { 82 | return lhs.hashValue == rhs.hashValue 83 | } 84 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/SwiftPriorityQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftPriorityQueue.swift 3 | // SwiftPriorityQueue 4 | // 5 | // Copyright (c) 2015-2017 David Kopec 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | 25 | // This code was inspired by Section 2.4 of Algorithms by Sedgewick & Wayne, 4th Edition 26 | 27 | /// A PriorityQueue takes objects to be pushed of any type that implements Comparable. 28 | /// It will pop the objects in the order that they would be sorted. A pop() or a push() 29 | /// can be accomplished in O(lg n) time. It can be specified whether the objects should 30 | /// be popped in ascending or descending order (Max Priority Queue or Min Priority Queue) 31 | /// at the time of initialization. 32 | public struct PriorityQueue { 33 | 34 | fileprivate var heap = [T]() 35 | private let ordered: (T, T) -> Bool 36 | 37 | public init(ascending: Bool = false, startingValues: [T] = []) { 38 | self.init(order: ascending ? { $0 > $1 } : { $0 < $1 }, startingValues: startingValues) 39 | } 40 | 41 | /// Creates a new PriorityQueue with the given ordering. 42 | /// 43 | /// - parameter order: A function that specifies whether its first argument should 44 | /// come after the second argument in the PriorityQueue. 45 | /// - parameter startingValues: An array of elements to initialize the PriorityQueue with. 46 | public init(order: @escaping (T, T) -> Bool, startingValues: [T] = []) { 47 | ordered = order 48 | 49 | // Based on "Heap construction" from Sedgewick p 323 50 | heap = startingValues 51 | var i = heap.count/2 - 1 52 | while i >= 0 { 53 | sink(i) 54 | i -= 1 55 | } 56 | } 57 | 58 | /// How many elements the Priority Queue stores 59 | public var count: Int { return heap.count } 60 | 61 | /// true if and only if the Priority Queue is empty 62 | public var isEmpty: Bool { return heap.isEmpty } 63 | 64 | /// Add a new element onto the Priority Queue. O(lg n) 65 | /// 66 | /// - parameter element: The element to be inserted into the Priority Queue. 67 | public mutating func push(_ element: T) { 68 | heap.append(element) 69 | swim(heap.count - 1) 70 | } 71 | 72 | /// Remove and return the element with the highest priority (or lowest if ascending). O(lg n) 73 | /// 74 | /// - returns: The element with the highest priority in the Priority Queue, or nil if the PriorityQueue is empty. 75 | public mutating func pop() -> T? { 76 | 77 | if heap.isEmpty { return nil } 78 | if heap.count == 1 { return heap.removeFirst() } // added for Swift 2 compatibility 79 | // so as not to call swap() with two instances of the same location 80 | heap.swapAt(0, heap.count - 1) 81 | let temp = heap.removeLast() 82 | sink(0) 83 | 84 | return temp 85 | } 86 | 87 | 88 | /// Removes the first occurence of a particular item. Finds it by value comparison using ==. O(n) 89 | /// Silently exits if no occurrence found. 90 | /// 91 | /// - parameter item: The item to remove the first occurrence of. 92 | public mutating func remove(_ item: T) { 93 | if let index = heap.index(of: item) { 94 | heap.swapAt(index, heap.count - 1) 95 | heap.removeLast() 96 | if index < heap.count { // if we removed the last item, nothing to swim 97 | swim(index) 98 | sink(index) 99 | } 100 | } 101 | } 102 | 103 | /// Removes all occurences of a particular item. Finds it by value comparison using ==. O(n) 104 | /// Silently exits if no occurrence found. 105 | /// 106 | /// - parameter item: The item to remove. 107 | public mutating func removeAll(_ item: T) { 108 | var lastCount = heap.count 109 | remove(item) 110 | while (heap.count < lastCount) { 111 | lastCount = heap.count 112 | remove(item) 113 | } 114 | } 115 | 116 | /// Get a look at the current highest priority item, without removing it. O(1) 117 | /// 118 | /// - returns: The element with the highest priority in the PriorityQueue, or nil if the PriorityQueue is empty. 119 | public func peek() -> T? { 120 | return heap.first 121 | } 122 | 123 | /// Eliminate all of the elements from the Priority Queue. 124 | public mutating func clear() { 125 | heap.removeAll(keepingCapacity: false) 126 | } 127 | 128 | // Based on example from Sedgewick p 316 129 | private mutating func sink(_ index: Int) { 130 | var index = index 131 | while 2 * index + 1 < heap.count { 132 | 133 | var j = 2 * index + 1 134 | 135 | if j < (heap.count - 1) && ordered(heap[j], heap[j + 1]) { j += 1 } 136 | if !ordered(heap[index], heap[j]) { break } 137 | 138 | heap.swapAt(index, j) 139 | index = j 140 | } 141 | } 142 | 143 | // Based on example from Sedgewick p 316 144 | private mutating func swim(_ index: Int) { 145 | var index = index 146 | while index > 0 && ordered(heap[(index - 1) / 2], heap[index]) { 147 | heap.swapAt((index - 1) / 2, index) 148 | index = (index - 1) / 2 149 | } 150 | } 151 | } 152 | 153 | // MARK: - GeneratorType 154 | extension PriorityQueue: IteratorProtocol { 155 | 156 | public typealias Element = T 157 | mutating public func next() -> Element? { return pop() } 158 | } 159 | 160 | // MARK: - SequenceType 161 | extension PriorityQueue: Sequence { 162 | 163 | public typealias Iterator = PriorityQueue 164 | public func makeIterator() -> Iterator { return self } 165 | } 166 | 167 | // MARK: - CollectionType 168 | extension PriorityQueue: Collection { 169 | 170 | public typealias Index = Int 171 | 172 | public var startIndex: Int { return heap.startIndex } 173 | public var endIndex: Int { return heap.endIndex } 174 | 175 | public subscript(i: Int) -> T { return heap[i] } 176 | 177 | public func index(after i: PriorityQueue.Index) -> PriorityQueue.Index { 178 | return heap.index(after: i) 179 | } 180 | } 181 | 182 | // MARK: - CustomStringConvertible, CustomDebugStringConvertible 183 | extension PriorityQueue: CustomStringConvertible, CustomDebugStringConvertible { 184 | 185 | public var description: String { return heap.description } 186 | public var debugDescription: String { return heap.debugDescription } 187 | } 188 | -------------------------------------------------------------------------------- /ios/Runner/ImagePalette/UIImage+PaletteExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+PaletteExtension.swift 3 | // ImagePalette 4 | // 5 | // Original created by Google/Android 6 | // Ported to Swift/iOS by Shaun Harrison 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIImage { 13 | 14 | /** 15 | Scale the image down so that it’s largest dimension is targetMaxDimension. 16 | If image is smaller than this, then it is returned. 17 | */ 18 | internal func scaleDown(to targetMaxDimension: CGFloat) -> UIImage? { 19 | let size = self.size 20 | let maxDimensionInPoints = max(size.width, size.height) 21 | let maxDimensionInPixels = maxDimensionInPoints * self.scale 22 | 23 | if maxDimensionInPixels <= targetMaxDimension { 24 | // If the bitmap is small enough already, just return it 25 | return self 26 | } 27 | 28 | let scaleRatio = targetMaxDimension / maxDimensionInPoints 29 | let width = round(size.width * scaleRatio) 30 | let height = round(size.height * scaleRatio) 31 | 32 | UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), true, 1.0) 33 | self.draw(in: CGRect(x: 0.0, y: 0.0, width: width, height: height)) 34 | let scaledImage = UIGraphicsGetImageFromCurrentImageContext() 35 | UIGraphicsEndImageContext() 36 | 37 | return scaledImage 38 | } 39 | 40 | internal var pixels: [Int64] { 41 | let image = self.cgImage! 42 | 43 | let pixelsWide = image.width 44 | let pixelsHigh = image.height 45 | 46 | let bitmapBytesPerRow = (pixelsWide * 4) 47 | let bitmapByteCount = (bitmapBytesPerRow * pixelsHigh) 48 | 49 | let colorSpace = CGColorSpaceCreateDeviceRGB() 50 | let bitmapData = malloc(bitmapByteCount) 51 | defer { free(bitmapData) } 52 | 53 | guard let context = CGContext(data: bitmapData, width: pixelsWide, height: pixelsHigh, bitsPerComponent: 8, bytesPerRow: bitmapBytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue) else { 54 | fatalError("Unable to create bitmap context") 55 | } 56 | 57 | guard let unconstrainedData = context.data else { 58 | fatalError("Unable to get bitmap data") 59 | } 60 | 61 | context.draw(image, in: CGRect(x: 0.0, y: 0.0, width: CGFloat(pixelsWide), height: CGFloat(pixelsHigh))) 62 | 63 | let data = unconstrainedData.bindMemory(to: UInt8.self, capacity: pixelsWide * pixelsHigh) 64 | var pixels = [Int64]() 65 | 66 | for x in 0 ..< pixelsWide { 67 | for y in 0 ..< pixelsHigh { 68 | let pixelInfo = ((pixelsWide * y) + x) * 4 69 | 70 | let alpha = Int64(data[pixelInfo]) 71 | let red = Int64(data[pixelInfo + 1]) 72 | let green = Int64(data[pixelInfo + 2]) 73 | let blue = Int64(data[pixelInfo + 3]) 74 | 75 | pixels.append(HexColor.fromARGB(alpha, red: red, green: green, blue: blue)) 76 | } 77 | } 78 | 79 | return pixels 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | 豆瓣电影Flutter版 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | doubanmovie_flutter 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1.1 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" -------------------------------------------------------------------------------- /ios/Runner/SwiftPalettePlugin.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | 4 | let PALETTE_CHANNEL = "channel:com.postmuseapp.designer/palette"; 5 | 6 | public class SwiftPalettePlugin: NSObject, FlutterPlugin { 7 | public static func register(with registrar: FlutterPluginRegistrar) { 8 | let channel = FlutterMethodChannel(name: PALETTE_CHANNEL, binaryMessenger: registrar.messenger()) 9 | let instance = SwiftPalettePlugin() 10 | registrar.addMethodCallDelegate(instance, channel: channel) 11 | } 12 | 13 | public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { 14 | let arguments = call.arguments as! Dictionary 15 | let path = arguments["path"] as? String 16 | let url = arguments["url"] as? String 17 | if ("getPalette" == call.method){ 18 | self.getPalette(path: path!, result:result) 19 | }else if("getPaletteWithUrl"==call.method){ 20 | self.getPaletteWithUrl(url: url!, result: result) 21 | } 22 | } 23 | 24 | func getPalette(path:String, result:@escaping FlutterResult){ 25 | getPalette(path: path, result: result, onPalette: {palette in 26 | result(self.convertPalette(palette: palette)) 27 | }) 28 | } 29 | 30 | func getPaletteWithUrl(url:String,result:@escaping FlutterResult){ 31 | getPaletteWithUrl(url: url, result: result,onPalette: {palette in 32 | result(self.convertPalette(palette: palette)) 33 | }) 34 | } 35 | 36 | func getPalette(path:String, result:FlutterResult, onPalette:@escaping (Palette)->Void){ 37 | do { 38 | let imageUrl = URL.init(fileURLWithPath: path) 39 | let imageData = try Data.init(contentsOf: imageUrl) 40 | let image = UIImage.init(data: imageData)! 41 | Palette.generateWith(configuration: PaletteConfiguration(image: image)) {palette in 42 | onPalette(palette) 43 | } 44 | } catch { 45 | print(error) 46 | result(error) 47 | } 48 | } 49 | 50 | func getPaletteWithUrl(url:String,result:FlutterResult,onPalette:@escaping(Palette)->Void){ 51 | do{ 52 | let imgUrl = URL.init(string: url) 53 | let uiImage = try UIImage.init(data: Data.init(contentsOf: imgUrl!)) 54 | Palette.generateWith(configuration: PaletteConfiguration(image: uiImage!)) {palette in 55 | onPalette(palette) 56 | } 57 | }catch{ 58 | print(error) 59 | result(error) 60 | } 61 | 62 | } 63 | 64 | func convertPalette(palette: Palette) -> Dictionary { 65 | var paletteMap = Dictionary() 66 | paletteMap["vibrant"] = convertSwatch(swatch:palette.vibrantSwatch) 67 | paletteMap["darkVibrant"] = convertSwatch(swatch:palette.darkVibrantSwatch) 68 | paletteMap["lightVibrant"] = convertSwatch(swatch:palette.lightVibrantSwatch) 69 | paletteMap["muted"] = convertSwatch(swatch:palette.mutedSwatch) 70 | paletteMap["darkMuted"] = convertSwatch(swatch:palette.darkMutedSwatch) 71 | paletteMap["lightMuted"] = convertSwatch(swatch:palette.lightMutedSwatch) 72 | paletteMap["swatches"] = palette.swatches.map{convertSwatch(swatch: $0)} 73 | return paletteMap; 74 | } 75 | 76 | func convertSwatch(swatch: PaletteSwatch?) -> Dictionary? { 77 | if (swatch != nil){ 78 | let swatch = swatch! 79 | var swatchMap = Dictionary() 80 | swatchMap["color"] = colorToARGB(color: swatch.color) 81 | swatchMap["population"] = swatch.population 82 | swatchMap["titleTextColor"] = colorToARGB(color: swatch.titleTextColor) 83 | swatchMap["bodyTextColor"] = colorToARGB(color: swatch.bodyTextColor) 84 | swatchMap["swatchInfo"] = swatch.debugDescription 85 | return swatchMap; 86 | } else { 87 | return nil 88 | } 89 | } 90 | 91 | func colorToARGB(color: UIColor?) -> Int?{ 92 | if (color == nil){ 93 | return nil 94 | } 95 | var red: CGFloat = 0.0 96 | var green: CGFloat = 0.0 97 | var blue: CGFloat = 0.0 98 | var alpha: CGFloat = 0.0 99 | color!.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 100 | 101 | let r = Int(round(red * 255.0)) 102 | let g = Int(round(green * 255.0)) 103 | let b = Int(round(blue * 255.0)) 104 | let a = Int(round(alpha * 255.0)) 105 | 106 | return (a << 24) | (r << 16) | (g << 8) | b 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/CustomView.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as Math; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class ScoreView extends StatelessWidget { 6 | double score; 7 | Size size; 8 | double padding; 9 | double spacing; 10 | 11 | ScoreView(this.padding, this.spacing, this.size, this.score); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return new CustomPaint( 16 | size: size, 17 | painter: new ScorePainter(score, padding, spacing), 18 | ); 19 | } 20 | } 21 | 22 | class ScorePainter extends CustomPainter { 23 | double _score; 24 | double padding = 10.0; //左右两边间距 25 | double spacing = 2.0; // 星星之间间距 26 | Paint _backgroundPaint; 27 | Paint _foregroundPaint; 28 | 29 | ScorePainter(num score, double padding, num spacing) { 30 | this._score = score; 31 | this.padding = padding; 32 | this.spacing = spacing; 33 | init(); 34 | } 35 | 36 | void init() { 37 | _backgroundPaint = new Paint(); 38 | _backgroundPaint.isAntiAlias = true; 39 | _backgroundPaint.color = Colors.grey; 40 | _backgroundPaint.strokeWidth = 1.0; 41 | _backgroundPaint.style = PaintingStyle.fill; 42 | 43 | _foregroundPaint = new Paint(); 44 | _foregroundPaint.isAntiAlias = true; 45 | _foregroundPaint.color = Colors.orange; 46 | _foregroundPaint.strokeWidth = 1.0; 47 | _foregroundPaint.style = PaintingStyle.fill; 48 | } 49 | 50 | @override 51 | void paint(Canvas canvas, Size size) { 52 | double outR = (size.width - 2 * padding - 4 * spacing) / 2 / 5; 53 | double inR = outR * sin(18) / sin(180 - 36 - 18); 54 | 55 | canvas.translate(padding + outR, outR); 56 | 57 | Path path; 58 | for (int i = 0; i < 5; i++) { 59 | if (_score > 2.0) { 60 | // 完整的星星 61 | path = getStarPath(outR, inR, 0.0, 0.0, 0.0); 62 | canvas.drawPath(path, _foregroundPaint); 63 | } else if (_score > 0.0) { 64 | // 不完整的星星 65 | path = getStarPath(outR, inR, 0.0, 0.0, 0.0); 66 | canvas.drawPath(path, _backgroundPaint); 67 | 68 | _foregroundPaint.blendMode = BlendMode.overlay; 69 | Rect rect = new Rect.fromLTWH(-outR, -outR, outR * _score, 2 * outR); 70 | canvas.drawRect(rect, _foregroundPaint); 71 | } else { 72 | // 灰色星星 73 | path = getStarPath(outR, inR, 0.0, 0.0, 0.0); 74 | canvas.drawPath(path, _backgroundPaint); 75 | } 76 | canvas.translate(spacing + outR * 2, 0.0); 77 | _score -= 2.0; 78 | } 79 | } 80 | 81 | @override 82 | bool shouldRepaint(CustomPainter oldDelegate) { 83 | return true; 84 | } 85 | 86 | getStarPath(double R, double r, double x, double y, double rot) { 87 | Path path = new Path(); 88 | 89 | path.moveTo(Math.cos((54 + 72 * -1 - rot) / 180 * Math.pi) * r + x, 90 | -Math.sin((54 + 72 * -1 - rot) / 180 * Math.pi) * r + y); 91 | 92 | for (var i = 0; i < 5; i++) { 93 | path.lineTo(Math.cos((18 + 72 * i - rot) / 180 * Math.pi) * R + x, 94 | -Math.sin((18 + 72 * i - rot) / 180 * Math.pi) * R + y); 95 | path.lineTo(Math.cos((54 + 72 * i - rot) / 180 * Math.pi) * r + x, 96 | -Math.sin((54 + 72 * i - rot) / 180 * Math.pi) * r + y); 97 | } 98 | path.close(); 99 | return path; 100 | } 101 | 102 | double cos(int num) { 103 | return Math.cos(num * Math.pi / 180); 104 | } 105 | 106 | double sin(int num) { 107 | return Math.sin(num * Math.pi / 180); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/NestScrollSample.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Sample extends StatelessWidget { 4 | var _tabs = ["1", "2", "3"]; 5 | @override 6 | Widget build(BuildContext context) { 7 | return new DefaultTabController( 8 | length: _tabs.length, // This is the number of tabs. 9 | child: new NestedScrollView( 10 | headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { 11 | // These are the slivers that show up in the "outer" scroll view. 12 | return [ 13 | new SliverOverlapAbsorber( 14 | // This widget takes the overlapping behavior of the SliverAppBar, 15 | // and redirects it to the SliverOverlapInjector below. If it is 16 | // missing, then it is possible for the nested "inner" scroll view 17 | // below to end up under the SliverAppBar even when the inner 18 | // scroll view thinks it has not been scrolled. 19 | // This is not necessary if the "headerSliverBuilder" only builds 20 | // widgets that do not overlap the next sliver. 21 | handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), 22 | child: new SliverAppBar( 23 | title: const Text('Books'), // This is the title in the app bar. 24 | pinned: true, 25 | expandedHeight: 150.0, 26 | // The "forceElevated" property causes the SliverAppBar to show 27 | // a shadow. The "innerBoxIsScrolled" parameter is true when the 28 | // inner scroll view is scrolled beyond its "zero" point, i.e. 29 | // when it appears to be scrolled below the SliverAppBar. 30 | // Without this, there are cases where the shadow would appear 31 | // or not appear inappropriately, because the SliverAppBar is 32 | // not actually aware of the precise position of the inner 33 | // scroll views. 34 | forceElevated: innerBoxIsScrolled, 35 | bottom: new TabBar( 36 | // These are the widgets to put in each tab in the tab bar. 37 | tabs: 38 | _tabs.map((String name) => new Tab(text: name)).toList(), 39 | ), 40 | ), 41 | ), 42 | ]; 43 | }, 44 | body: new TabBarView( 45 | // These are the contents of the tab views, below the tabs. 46 | children: _tabs.map((String name) { 47 | return new SafeArea( 48 | top: false, 49 | bottom: false, 50 | child: new Builder( 51 | // This Builder is needed to provide a BuildContext that is "inside" 52 | // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can 53 | // find the NestedScrollView. 54 | builder: (BuildContext context) { 55 | return new CustomScrollView( 56 | // The "controller" and "primary" members should be left 57 | // unset, so that the NestedScrollView can control this 58 | // inner scroll view. 59 | // If the "controller" property is set, then this scroll 60 | // view will not be associated with the NestedScrollView. 61 | // The PageStorageKey should be unique to this ScrollView; 62 | // it allows the list to remember its scroll position when 63 | // the tab view is not on the screen. 64 | key: new PageStorageKey(name), 65 | slivers: [ 66 | new SliverOverlapInjector( 67 | // This is the flip side of the SliverOverlapAbsorber above. 68 | handle: NestedScrollView 69 | .sliverOverlapAbsorberHandleFor(context), 70 | ), 71 | new SliverPadding( 72 | padding: const EdgeInsets.all(8.0), 73 | // In this example, the inner scroll view has 74 | // fixed-height list items, hence the use of 75 | // SliverFixedExtentList. However, one could use any 76 | // sliver widget here, e.g. SliverList or SliverGrid. 77 | sliver: new SliverFixedExtentList( 78 | // The items in this example are fixed to 48 pixels 79 | // high. This matches the Material Design spec for 80 | // ListTile widgets. 81 | itemExtent: 48.0, 82 | delegate: new SliverChildBuilderDelegate( 83 | (BuildContext context, int index) { 84 | // This builder is called for each child. 85 | // In this example, we just number each list item. 86 | return new ListTile( 87 | title: new Text('Item $index'), 88 | ); 89 | }, 90 | // The childCount of the SliverChildBuilderDelegate 91 | // specifies how many children this inner list 92 | // has. In this example, each tab has a list of 93 | // exactly 30 items, but this is arbitrary. 94 | childCount: 30, 95 | ), 96 | ), 97 | ), 98 | ], 99 | ); 100 | }, 101 | ), 102 | ); 103 | }).toList(), 104 | ), 105 | ), 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:doubanmovie_flutter/pages/explore_page.dart'; 2 | import 'package:doubanmovie_flutter/pages/hotlist_page.dart'; 3 | import 'package:doubanmovie_flutter/pages/more_page.dart'; 4 | import 'package:flutter/cupertino.dart'; 5 | import 'package:flutter/material.dart'; 6 | 7 | void main() => runApp(new DoubanMovieClient()); 8 | 9 | class DoubanMovieClient extends StatefulWidget { 10 | @override 11 | State createState() { 12 | return new ClientState(); 13 | } 14 | } 15 | 16 | class ClientState extends State { 17 | int _tabIndex = 0; 18 | final tabTextStyleNormal = new TextStyle(color: const Color(0xff999999)); 19 | final tabTextStyleSelected = new TextStyle(color: const Color(0xff4c4c4c)); 20 | 21 | var tabImages; 22 | var _body; 23 | var appBarTitles = ['首页', '发现', '更多']; 24 | 25 | Image getTabImage(path) { 26 | return new Image.asset(path, width: 20.0, height: 20.0); 27 | } 28 | 29 | void initData() { 30 | if (tabImages == null) { 31 | tabImages = [ 32 | [ 33 | getTabImage('images/icon_hot_normal.png'), 34 | getTabImage('images/icon_hot_selected.png') 35 | ], 36 | [ 37 | getTabImage('images/icon_explore_normal.png'), 38 | getTabImage('images/icon_explore_selected.png') 39 | ], 40 | [ 41 | getTabImage('images/icon_more_normal.png'), 42 | getTabImage('images/icon_more_selected.png') 43 | ] 44 | ]; 45 | } 46 | _body = new IndexedStack( 47 | children: [ 48 | new HotListPage(), 49 | new ExplorePage(), 50 | new MorePage(), 51 | new HotListPage() 52 | ], 53 | index: _tabIndex, 54 | ); 55 | } 56 | 57 | TextStyle getTabTextStyle(int curIndex) { 58 | if (curIndex == _tabIndex) { 59 | return tabTextStyleSelected; 60 | } 61 | return tabTextStyleNormal; 62 | } 63 | 64 | Image getTabIcon(int curIndex) { 65 | if (curIndex == _tabIndex) { 66 | return tabImages[curIndex][1]; 67 | } 68 | return tabImages[curIndex][0]; 69 | } 70 | 71 | Text getTabTitle(int curIndex) { 72 | return new Text(appBarTitles[curIndex], style: getTabTextStyle(curIndex)); 73 | } 74 | 75 | @override 76 | Widget build(BuildContext context) { 77 | initData(); 78 | 79 | final CupertinoTabBar botNavBar = new CupertinoTabBar( 80 | /* 81 | * 在底部导航栏中布置的交互项:迭代存储NavigationIconView类的列表 82 | * 返回此迭代的每个元素的底部导航栏项目 83 | * 创建包含此迭代的元素的列表 84 | */ 85 | items: [ 86 | new BottomNavigationBarItem( 87 | icon: getTabIcon(0), title: getTabTitle(0)), 88 | new BottomNavigationBarItem( 89 | icon: getTabIcon(1), title: getTabTitle(1)), 90 | new BottomNavigationBarItem( 91 | icon: getTabIcon(2), title: getTabTitle(2)), 92 | ], 93 | // 当前活动项的索引:存储底部导航栏的当前选择 94 | currentIndex: _tabIndex, 95 | // 当点击项目时调用的回调 96 | onTap: (int index) { 97 | setState(() { 98 | _tabIndex = index; 99 | }); 100 | }); 101 | 102 | return new MaterialApp( 103 | theme: new ThemeData(primaryColor: const Color(0xFF63CA6C)), 104 | home: new Scaffold( 105 | body: _body, 106 | bottomNavigationBar: botNavBar, 107 | ), 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /lib/model/MovieActor.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | import 'MovieImgs.dart'; 4 | 5 | part 'MovieActor.g.dart'; 6 | 7 | @JsonSerializable() 8 | class MovieActor extends Object with _$MovieActorSerializerMixin { 9 | String alt; 10 | MovieImgs avatars; 11 | String name; 12 | String id; 13 | 14 | MovieActor(this.alt, this.avatars, this.name, this.id); 15 | 16 | factory MovieActor.fromJson(Map json) => 17 | _$MovieActorFromJson(json); 18 | } 19 | -------------------------------------------------------------------------------- /lib/model/MovieActor.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieActor.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieActor _$MovieActorFromJson(Map json) { 10 | return new MovieActor( 11 | json['alt'] as String, 12 | json['avatars'] == null 13 | ? null 14 | : new MovieImgs.fromJson(json['avatars'] as Map), 15 | json['name'] as String, 16 | json['id'] as String); 17 | } 18 | 19 | abstract class _$MovieActorSerializerMixin { 20 | String get alt; 21 | MovieImgs get avatars; 22 | String get name; 23 | String get id; 24 | Map toJson() => 25 | {'alt': alt, 'avatars': avatars, 'name': name, 'id': id}; 26 | } 27 | -------------------------------------------------------------------------------- /lib/model/MovieDetail.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | import 'MovieActor.dart'; 4 | import 'MovieImgs.dart'; 5 | import 'MovieRate.dart'; 6 | 7 | part 'MovieDetail.g.dart'; 8 | 9 | @JsonSerializable() 10 | class MovieDetail extends Object with _$MovieDetailSerializerMixin { 11 | MovieRate rating; 12 | int reviews_count; 13 | int wish_count; 14 | String year; 15 | MovieImgs images; 16 | String alt; 17 | String id; 18 | String mobile_url; 19 | String title; 20 | String share_url; 21 | List genres; 22 | int collect_count; 23 | List casts; 24 | String original_title; 25 | String summary; 26 | List directors; 27 | int comments_count; 28 | int ratings_count; 29 | 30 | MovieDetail( 31 | this.rating, 32 | this.reviews_count, 33 | this.wish_count, 34 | this.year, 35 | this.images, 36 | this.alt, 37 | this.id, 38 | this.mobile_url, 39 | this.title, 40 | this.share_url, 41 | this.genres, 42 | this.collect_count, 43 | this.casts, 44 | this.original_title, 45 | this.summary, 46 | this.directors, 47 | this.comments_count, 48 | this.ratings_count); 49 | 50 | factory MovieDetail.fromJson(Map json) => 51 | _$MovieDetailFromJson(json); 52 | } 53 | -------------------------------------------------------------------------------- /lib/model/MovieDetail.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieDetail.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieDetail _$MovieDetailFromJson(Map json) { 10 | return new MovieDetail( 11 | json['rating'] == null 12 | ? null 13 | : new MovieRate.fromJson(json['rating'] as Map), 14 | json['reviews_count'] as int, 15 | json['wish_count'] as int, 16 | json['year'] as String, 17 | json['images'] == null 18 | ? null 19 | : new MovieImgs.fromJson(json['images'] as Map), 20 | json['alt'] as String, 21 | json['id'] as String, 22 | json['mobile_url'] as String, 23 | json['title'] as String, 24 | json['share_url'] as String, 25 | (json['genres'] as List)?.map((e) => e as String)?.toList(), 26 | json['collect_count'] as int, 27 | (json['casts'] as List) 28 | ?.map((e) => e == null 29 | ? null 30 | : new MovieActor.fromJson(e as Map)) 31 | ?.toList(), 32 | json['original_title'] as String, 33 | json['summary'] as String, 34 | (json['directors'] as List) 35 | ?.map((e) => e == null 36 | ? null 37 | : new MovieActor.fromJson(e as Map)) 38 | ?.toList(), 39 | json['comments_count'] as int, 40 | json['ratings_count'] as int); 41 | } 42 | 43 | abstract class _$MovieDetailSerializerMixin { 44 | MovieRate get rating; 45 | int get reviews_count; 46 | int get wish_count; 47 | String get year; 48 | MovieImgs get images; 49 | String get alt; 50 | String get id; 51 | String get mobile_url; 52 | String get title; 53 | String get share_url; 54 | List get genres; 55 | int get collect_count; 56 | List get casts; 57 | String get original_title; 58 | String get summary; 59 | List get directors; 60 | int get comments_count; 61 | int get ratings_count; 62 | Map toJson() => { 63 | 'rating': rating, 64 | 'reviews_count': reviews_count, 65 | 'wish_count': wish_count, 66 | 'year': year, 67 | 'images': images, 68 | 'alt': alt, 69 | 'id': id, 70 | 'mobile_url': mobile_url, 71 | 'title': title, 72 | 'share_url': share_url, 73 | 'genres': genres, 74 | 'collect_count': collect_count, 75 | 'casts': casts, 76 | 'original_title': original_title, 77 | 'summary': summary, 78 | 'directors': directors, 79 | 'comments_count': comments_count, 80 | 'ratings_count': ratings_count 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /lib/model/MovieImgs.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'MovieImgs.g.dart'; 4 | 5 | @JsonSerializable() 6 | class MovieImgs extends Object with _$MovieImgsSerializerMixin { 7 | String small; 8 | String large; 9 | String medium; 10 | 11 | MovieImgs(this.small, this.large, this.medium); 12 | 13 | factory MovieImgs.fromJson(Map json) => 14 | _$MovieImgsFromJson(json); 15 | } 16 | -------------------------------------------------------------------------------- /lib/model/MovieImgs.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieImgs.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieImgs _$MovieImgsFromJson(Map json) { 10 | return new MovieImgs(json['small'] as String, json['large'] as String, 11 | json['medium'] as String); 12 | } 13 | 14 | abstract class _$MovieImgsSerializerMixin { 15 | String get small; 16 | String get large; 17 | String get medium; 18 | Map toJson() => 19 | {'small': small, 'large': large, 'medium': medium}; 20 | } 21 | -------------------------------------------------------------------------------- /lib/model/MovieIntro.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | import 'MovieActor.dart'; 4 | import 'MovieImgs.dart'; 5 | import 'MovieRate.dart'; 6 | 7 | part 'MovieIntro.g.dart'; 8 | 9 | ///电影概要信息 10 | @JsonSerializable() 11 | class MovieIntro extends Object with _$MovieIntroSerializerMixin { 12 | MovieRate rating; 13 | List genres; 14 | String title; 15 | List casts; 16 | int collect_count; 17 | String original_title; 18 | String subtype; 19 | List directors; 20 | String year; 21 | MovieImgs images; 22 | String alt; 23 | String id; 24 | 25 | MovieIntro( 26 | this.rating, 27 | this.genres, 28 | this.title, 29 | this.casts, 30 | this.collect_count, 31 | this.original_title, 32 | this.subtype, 33 | this.directors, 34 | this.year, 35 | this.images, 36 | this.alt, 37 | this.id); 38 | 39 | factory MovieIntro.fromJson(Map json) => 40 | _$MovieIntroFromJson(json); 41 | } 42 | -------------------------------------------------------------------------------- /lib/model/MovieIntro.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieIntro.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieIntro _$MovieIntroFromJson(Map json) { 10 | return new MovieIntro( 11 | json['rating'] == null 12 | ? null 13 | : new MovieRate.fromJson(json['rating'] as Map), 14 | (json['genres'] as List)?.map((e) => e as String)?.toList(), 15 | json['title'] as String, 16 | (json['casts'] as List) 17 | ?.map((e) => e == null 18 | ? null 19 | : new MovieActor.fromJson(e as Map)) 20 | ?.toList(), 21 | json['collect_count'] as int, 22 | json['original_title'] as String, 23 | json['subtype'] as String, 24 | (json['directors'] as List) 25 | ?.map((e) => e == null 26 | ? null 27 | : new MovieActor.fromJson(e as Map)) 28 | ?.toList(), 29 | json['year'] as String, 30 | json['images'] == null 31 | ? null 32 | : new MovieImgs.fromJson(json['images'] as Map), 33 | json['alt'] as String, 34 | json['id'] as String); 35 | } 36 | 37 | abstract class _$MovieIntroSerializerMixin { 38 | MovieRate get rating; 39 | List get genres; 40 | String get title; 41 | List get casts; 42 | int get collect_count; 43 | String get original_title; 44 | String get subtype; 45 | List get directors; 46 | String get year; 47 | MovieImgs get images; 48 | String get alt; 49 | String get id; 50 | Map toJson() => { 51 | 'rating': rating, 52 | 'genres': genres, 53 | 'title': title, 54 | 'casts': casts, 55 | 'collect_count': collect_count, 56 | 'original_title': original_title, 57 | 'subtype': subtype, 58 | 'directors': directors, 59 | 'year': year, 60 | 'images': images, 61 | 'alt': alt, 62 | 'id': id 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /lib/model/MovieIntroList.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | import 'MovieIntro.dart'; 4 | 5 | part 'MovieIntroList.g.dart'; 6 | 7 | @JsonSerializable() 8 | class MovieIntroList extends Object with _$MovieIntroListSerializerMixin { 9 | List subjects; 10 | 11 | MovieIntroList(this.subjects); 12 | 13 | factory MovieIntroList.fromJson(Map json) => 14 | _$MovieIntroListFromJson(json); 15 | } 16 | -------------------------------------------------------------------------------- /lib/model/MovieIntroList.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieIntroList.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieIntroList _$MovieIntroListFromJson(Map json) { 10 | return new MovieIntroList((json['subjects'] as List) 11 | ?.map((e) => 12 | e == null ? null : new MovieIntro.fromJson(e as Map)) 13 | ?.toList()); 14 | } 15 | 16 | abstract class _$MovieIntroListSerializerMixin { 17 | List get subjects; 18 | Map toJson() => {'subjects': subjects}; 19 | } 20 | -------------------------------------------------------------------------------- /lib/model/MovieRate.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'MovieRate.g.dart'; 4 | 5 | @JsonSerializable() 6 | class MovieRate extends Object with _$MovieRateSerializerMixin { 7 | double max; 8 | double average; 9 | String stars; 10 | double min; 11 | 12 | MovieRate(this.max, this.average, this.stars, this.min); 13 | 14 | factory MovieRate.fromJson(Map json) => 15 | _$MovieRateFromJson(json); 16 | } 17 | -------------------------------------------------------------------------------- /lib/model/MovieRate.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'MovieRate.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MovieRate _$MovieRateFromJson(Map json) { 10 | return new MovieRate( 11 | (json['max'] as num)?.toDouble(), 12 | (json['average'] as num)?.toDouble(), 13 | json['stars'] as String, 14 | (json['min'] as num)?.toDouble()); 15 | } 16 | 17 | abstract class _$MovieRateSerializerMixin { 18 | double get max; 19 | double get average; 20 | String get stars; 21 | double get min; 22 | Map toJson() => { 23 | 'max': max, 24 | 'average': average, 25 | 'stars': stars, 26 | 'min': min 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /lib/net/DateSource.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:html/dom.dart'; 4 | import 'package:html/parser.dart' show parse; 5 | import 'package:http/http.dart' as http; 6 | 7 | void main() { 8 | new DataSource().getHotFilmCritics(); 9 | } 10 | 11 | class DataSource { 12 | void a() { 13 | http 14 | .get('https://movie.douban.com/subject/26804147/photos?type=R') 15 | .then((http.Response response) { 16 | var document = parse(response.body.toString()); 17 | String imgId = document 18 | .getElementsByClassName('poster-col3 clearfix')[0] 19 | .getElementsByTagName('li')[0] 20 | .attributes['data-id'] 21 | .toString(); 22 | print("https://img1.doubanio.com/view/photo/l/public/p" + imgId + ".jpg"); 23 | }); 24 | } 25 | 26 | void getHdCover() { 27 | Map hdImgMap; 28 | http 29 | .get('https://movie.douban.com/cinema/nowplaying/beijing/') 30 | .then((http.Response response) { 31 | var document = parse(response.body.toString()); 32 | List items = document.getElementsByClassName('list-item'); 33 | hdImgMap = new Map(); 34 | for (var item in items) { 35 | var url = 36 | item.getElementsByTagName('img')[0].attributes['src'].toString(); 37 | String movieId = item.attributes['data-subject'].toString(); 38 | 39 | RegExp exp = new RegExp('public\/p.+\.jpg'); 40 | String s = exp.firstMatch(url).group(0); 41 | 42 | String imgId = s.substring(8, 18); 43 | String imgUrl = 44 | "https://img1.doubanio.com/view/photo/l/public/p" + imgId + ".jpg"; 45 | print(imgUrl); 46 | hdImgMap.addAll({movieId: imgUrl}); 47 | } 48 | }); 49 | } 50 | 51 | Future getNewList() async { 52 | List> list = []; 53 | await http 54 | .get('https://movie.douban.com/chart') 55 | .then((http.Response response) { 56 | var document = parse(response.body.toString()); 57 | List newList = document 58 | .getElementsByClassName('indent')[0] 59 | .getElementsByTagName('table'); 60 | 61 | for (var item in newList) { 62 | Map movie = new Map(); 63 | 64 | String id = item.getElementsByTagName('a')[0].attributes['href']; 65 | String title = item.getElementsByTagName('a')[0].attributes['title']; 66 | String score = item 67 | .getElementsByClassName('star clearfix')[0] 68 | .getElementsByClassName('rating_nums')[0] 69 | .text; 70 | 71 | String num = item 72 | .getElementsByClassName('star clearfix')[0] 73 | .getElementsByClassName('pl')[0] 74 | .text; 75 | 76 | String img = item.getElementsByTagName('img')[0].attributes['src']; 77 | 78 | print(title); 79 | print(id); 80 | print(score); 81 | print(num); 82 | print(img); 83 | movie['title'] = title; 84 | movie['id'] = id; 85 | movie[score] = score; 86 | movie['num'] = num; 87 | movie['img'] = img; 88 | 89 | list.add(movie); 90 | print('---------------------'); 91 | //hdImgMap.addAll({movieId: imgUrl}); 92 | } 93 | return list; 94 | }); 95 | } 96 | 97 | void getWeeklyList() { 98 | http.get('https://movie.douban.com/chart').then((http.Response response) { 99 | var document = parse(response.body.toString()); 100 | List newList = document 101 | .getElementById('listCont2') 102 | .getElementsByClassName('clearfix'); 103 | 104 | for (int i = 1; i < newList.length; i++) { 105 | var info = newList[i].getElementsByTagName('a')[0]; 106 | var rank = newList[i] 107 | .getElementsByTagName('span')[0] 108 | .getElementsByTagName('div')[0]; 109 | String url = info.attributes['href']; 110 | String ranking = rank.attributes['class']; 111 | String change = rank.text.trim(); 112 | String movieId = url.split('/')[4].trim(); 113 | print(movieId); 114 | print(info.text.trim()); 115 | print(url); 116 | print(ranking + change); 117 | 118 | print('-------------------------------------------'); 119 | } 120 | }); 121 | } 122 | 123 | static void getWeeklyData() { 124 | List> list = []; 125 | http.get('https://movie.douban.com/chart').then((http.Response response) { 126 | var document = parse(response.body.toString()); 127 | print('1'); 128 | //print(response.body.toString()); 129 | List newList = document 130 | .getElementById('listCont2') 131 | .getElementsByClassName('clearfix'); 132 | print(document.getElementById('listCont2').text); 133 | for (int i = 1; i < newList.length; i++) { 134 | print('2'); 135 | Map movie = new Map(); 136 | 137 | var info = newList[i].getElementsByTagName('a')[0]; 138 | var rank = newList[i] 139 | .getElementsByTagName('span')[0] 140 | .getElementsByTagName('div')[0]; 141 | String url = info.attributes['href']; 142 | String ranking = rank.attributes['class']; 143 | String change = rank.text.trim(); 144 | String movieId = url.split('/')[4].trim(); 145 | 146 | print(info.text.trim()); 147 | print(url); 148 | print(ranking + change); 149 | movie['rankid'] = i.toString(); 150 | movie['title'] = info.text.trim(); 151 | movie['url'] = url; 152 | movie['rank'] = ranking; 153 | movie['change'] = change; 154 | movie['id'] = movieId; 155 | 156 | list.add(movie); 157 | print('-------------------------------------------'); 158 | } 159 | }); 160 | } 161 | 162 | List> getHotFilmCritics() { 163 | List> hotFilmCritics = []; 164 | http 165 | .get('https://movie.douban.com/subject/3878007/') 166 | .then((http.Response response) { 167 | var document = parse(response.body.toString()); 168 | 169 | List commentList = document 170 | .getElementById('hot-comments') 171 | .getElementsByClassName('comment-item'); 172 | 173 | for (var comment in commentList) { 174 | Map criticsItem = new Map(); 175 | 176 | print('---------------'); 177 | 178 | String authorName = comment 179 | .getElementsByClassName('comment-info')[0] 180 | .getElementsByTagName('a')[0] 181 | .text; 182 | criticsItem['author'] = authorName; 183 | print('作者:' + authorName); 184 | 185 | String rate = 186 | comment.getElementsByClassName('rating')[0].attributes['title']; 187 | criticsItem['rate'] = rate; 188 | print('评分:' + rate); 189 | 190 | String starNum = 191 | comment.getElementsByClassName('rating')[0].attributes['class']; 192 | criticsItem['stars'] = starNum; 193 | print('星星:' + starNum.substring(0, 9)); 194 | 195 | String votes = comment.getElementsByClassName('votes')[0].text; 196 | criticsItem['votes'] = votes; 197 | print('获赞:' + votes); 198 | 199 | String commentTime = 200 | comment.getElementsByClassName('comment-time ')[0].text.trim(); 201 | criticsItem['commentTime'] = commentTime; 202 | print('时间:' + commentTime); 203 | 204 | String commentShort = comment.getElementsByClassName('short')[0].text; 205 | criticsItem['commentShort'] = commentShort; 206 | print('评论:' + commentShort); 207 | 208 | if (comment.getElementsByClassName('hide-item').length > 0) { 209 | String commentFull = 210 | comment.getElementsByClassName('hide-item')[0].text; 211 | criticsItem['commentFull'] = commentFull; 212 | print('全长评论:' + commentFull); 213 | } 214 | hotFilmCritics.add(criticsItem); 215 | } 216 | 217 | print('==============='); 218 | for (var i in hotFilmCritics) { 219 | print(i); 220 | } 221 | return hotFilmCritics; 222 | }); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /lib/net/api.dart: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/lib/net/api.dart -------------------------------------------------------------------------------- /lib/pages/explore_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:doubanmovie_flutter/CustomView.dart'; 4 | import 'package:doubanmovie_flutter/model/MovieIntro.dart'; 5 | import 'package:doubanmovie_flutter/model/MovieIntroList.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:html/dom.dart' as dom; 8 | import 'package:html/parser.dart' show parse; 9 | import 'package:http/http.dart' as http; 10 | 11 | import 'movie_detail_page.dart'; 12 | 13 | class ExplorePage extends StatefulWidget { 14 | @override 15 | State createState() => new ExplorePageState(); 16 | } 17 | 18 | class ExplorePageState extends State { 19 | List _comingSoonData; 20 | List _newMovieList; 21 | List _weeklyRankList; 22 | 23 | final PageController _pageController = new PageController(); 24 | double _currentPage = 0.0; 25 | 26 | @override 27 | void initState() { 28 | // 即将上映 29 | loadComningSoonData(); 30 | // 新片排行 31 | loadNewRankData(); 32 | // 周排行 33 | getWeeklyData(); 34 | } 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | if (_comingSoonData == null || 39 | _newMovieList == null || 40 | _weeklyRankList == null) { 41 | return new Center( 42 | child: new CircularProgressIndicator(), 43 | ); 44 | } else { 45 | return ListView( 46 | children: [ 47 | getComingSoonTitle(), 48 | new Container( 49 | height: 265.0, 50 | child: new ListView( 51 | physics: const PageScrollPhysics( 52 | parent: const BouncingScrollPhysics()), 53 | scrollDirection: Axis.horizontal, 54 | children: new List.generate(_comingSoonData.length, (int index) { 55 | return getComingSoonItem(index); 56 | }), 57 | ), 58 | ), 59 | new Container( 60 | height: 500.0, 61 | padding: new EdgeInsets.only(top: 15.0), 62 | child: new PageView( 63 | controller: PageController(viewportFraction: 0.85), 64 | scrollDirection: Axis.horizontal, 65 | children: [ 66 | getWeeklyPager(), 67 | getNewPager(), 68 | ], 69 | ), 70 | ), 71 | ], 72 | ); 73 | } 74 | } 75 | 76 | Widget getComingSoonTitle() { 77 | return new Container( 78 | child: new Text( 79 | '即将上映', 80 | style: new TextStyle( 81 | color: Colors.black, fontSize: 34.0, fontWeight: FontWeight.bold), 82 | ), 83 | padding: new EdgeInsets.only(top: 30.0, left: 15.0, bottom: 20.0), 84 | ); 85 | } 86 | 87 | Widget getComingSoonItem(int index) { 88 | return new InkWell( 89 | onTap: () { 90 | toDetailPage(_comingSoonData[index].id); 91 | }, 92 | child: new Column( 93 | crossAxisAlignment: CrossAxisAlignment.start, 94 | children: [ 95 | new Container( 96 | child: new Text( 97 | _comingSoonData[index].title, 98 | style: new TextStyle( 99 | color: Colors.black, fontWeight: FontWeight.bold), 100 | ), 101 | padding: new EdgeInsets.only(left: 17.0, bottom: 5.0), 102 | ), 103 | new Container( 104 | height: 230.0, 105 | padding: new EdgeInsets.only(left: 15.0), 106 | child: new AspectRatio( 107 | aspectRatio: 28.0 / 37.0, 108 | child: new Card( 109 | child: new Image.network( 110 | _comingSoonData[index].images.large, 111 | fit: BoxFit.fill, 112 | ), 113 | ), 114 | ), 115 | ), 116 | ], 117 | ), 118 | ); 119 | } 120 | 121 | Widget getNewPager() { 122 | return new Container( 123 | padding: new EdgeInsets.only(left: 17.0), 124 | child: new Column( 125 | crossAxisAlignment: CrossAxisAlignment.start, 126 | children: [ 127 | new Text( 128 | '新片榜', 129 | style: new TextStyle( 130 | color: Colors.black, 131 | fontWeight: FontWeight.bold, 132 | fontSize: 20.0), 133 | ), 134 | new Container( 135 | height: 400.0, 136 | padding: new EdgeInsets.only(top: 5.0), 137 | child: new ListView( 138 | physics: new NeverScrollableScrollPhysics(), 139 | scrollDirection: Axis.vertical, 140 | children: new List.generate(5, (int index) { 141 | return getNewPagerItem(index); 142 | }), 143 | ), 144 | ), 145 | new Align( 146 | alignment: new Alignment(0.0, 0.0), 147 | child: new Text( 148 | '显示全部(共10部)', 149 | style: new TextStyle(fontSize: 13.0, color: Colors.grey), 150 | ), 151 | ), 152 | ], 153 | ), 154 | ); 155 | } 156 | 157 | Widget getNewPagerItem(int index) { 158 | return new InkWell( 159 | onTap: () { 160 | toDetailPage(_newMovieList[index]['id']); 161 | }, 162 | child: new Container( 163 | padding: new EdgeInsets.only(top: 5.0), 164 | child: new Row( 165 | children: [ 166 | Image.network( 167 | _newMovieList[index]['img'], 168 | width: 50.0, 169 | height: 72.0, 170 | fit: BoxFit.fill, 171 | ), 172 | new Container( 173 | padding: new EdgeInsets.only(left: 5.0), 174 | child: new Column( 175 | crossAxisAlignment: CrossAxisAlignment.start, 176 | children: [ 177 | new Text( 178 | _newMovieList[index]['title'], 179 | style: new TextStyle( 180 | fontSize: 12.0, 181 | color: Colors.black, 182 | ), 183 | ), 184 | new ScoreView(0.0, 2.0, new Size(80.0, 20.0), 185 | double.parse(_newMovieList[index]['score'])), 186 | new Text( 187 | _newMovieList[index]['score'] + '分', 188 | style: new TextStyle( 189 | fontSize: 10.0, 190 | color: Colors.grey, 191 | ), 192 | ), 193 | new Text( 194 | _newMovieList[index]['num'], 195 | style: new TextStyle( 196 | fontSize: 10.0, 197 | color: Colors.grey, 198 | ), 199 | ), 200 | ], 201 | ), 202 | ), 203 | ], 204 | ), 205 | ), 206 | ); 207 | } 208 | 209 | Widget getWeeklyPager() { 210 | return new Container( 211 | padding: new EdgeInsets.only(left: 15.0), 212 | child: new Column( 213 | crossAxisAlignment: CrossAxisAlignment.start, 214 | children: [ 215 | new Text( 216 | '本周口碑榜', 217 | style: new TextStyle( 218 | color: Colors.black, 219 | fontWeight: FontWeight.bold, 220 | fontSize: 20.0), 221 | ), 222 | _weeklyRankList.length == 0 223 | ? new Container( 224 | height: 350.0, 225 | child: new Center( 226 | child: new Text( 227 | '暂 无 数 据', 228 | style: new TextStyle(fontSize: 16.0), 229 | ), 230 | ), 231 | ) 232 | : new Container( 233 | height: 450.0, 234 | padding: new EdgeInsets.only(top: 5.0), 235 | child: new ListView( 236 | physics: new NeverScrollableScrollPhysics(), 237 | scrollDirection: Axis.vertical, 238 | children: 239 | new List.generate(_weeklyRankList.length, (int index) { 240 | return getWeeklyPageItem(index); 241 | }), 242 | ), 243 | ), 244 | ], 245 | ), 246 | ); 247 | } 248 | 249 | Widget getWeeklyPageItem(int index) { 250 | return new InkWell( 251 | onTap: () { 252 | toDetailPage(_weeklyRankList[index]['id']); 253 | }, 254 | child: new Container( 255 | decoration: BoxDecoration( 256 | color: Colors.grey.shade100, 257 | ), 258 | padding: new EdgeInsets.only(top: 8.0, bottom: 8.0, left: 3.0), 259 | margin: new EdgeInsets.only(top: 3.0), 260 | child: new Row( 261 | children: [ 262 | new Text( 263 | _weeklyRankList[index]['rankid'] + '.', 264 | style: new TextStyle(color: Colors.grey), 265 | ), 266 | new Container( 267 | width: 250.0, 268 | padding: new EdgeInsets.only( 269 | left: 5.0, 270 | right: 5.0, 271 | ), 272 | child: new Text( 273 | _weeklyRankList[index]['title'], 274 | maxLines: 1, 275 | style: new TextStyle(color: Colors.black), 276 | ), 277 | ), 278 | new Align( 279 | alignment: new Alignment(1.0, 0.0), 280 | child: new Row( 281 | children: [ 282 | _weeklyRankList[index]['rank'] == 'up' 283 | ? new Image.asset('images/icon_ranking_up.png', 284 | width: 10.0, height: 10.0) 285 | : new Image.asset('images/icon_ranking_down.png', 286 | width: 10.0, height: 10.0), 287 | new Text(_weeklyRankList[index]['change']), 288 | ], 289 | ), 290 | ), 291 | ], 292 | ), 293 | ), 294 | ); 295 | } 296 | 297 | void loadComningSoonData() { 298 | http 299 | .get('https://api.douban.com/v2/movie/coming_soon') 300 | .then((http.Response response) { 301 | JsonDecoder jsonDecoder = new JsonDecoder(); 302 | Map map = jsonDecoder.convert(response.body); 303 | 304 | MovieIntroList list = new MovieIntroList.fromJson(map); 305 | setState(() { 306 | _comingSoonData = list.subjects; 307 | }); 308 | }); 309 | } 310 | 311 | //爬取新片榜 312 | void loadNewRankData() { 313 | List> list = []; 314 | http.get('https://movie.douban.com/chart').then((http.Response response) { 315 | var document = parse(response.body.toString()); 316 | List newList = document 317 | .getElementsByClassName('indent')[0] 318 | .getElementsByTagName('table'); 319 | 320 | for (var item in newList) { 321 | Map movie = new Map(); 322 | 323 | String url = item.getElementsByTagName('a')[0].attributes['href']; 324 | String title = item.getElementsByTagName('a')[0].attributes['title']; 325 | String score = item 326 | .getElementsByClassName('star clearfix')[0] 327 | .getElementsByClassName('rating_nums')[0] 328 | .text; 329 | 330 | String num = item 331 | .getElementsByClassName('star clearfix')[0] 332 | .getElementsByClassName('pl')[0] 333 | .text; 334 | 335 | String img = item.getElementsByTagName('img')[0].attributes['src']; 336 | String movieId = url.split('/')[4].trim(); 337 | print(title); 338 | print(url); 339 | print(score); 340 | print(num); 341 | print(img); 342 | movie['title'] = title; 343 | movie['id'] = movieId; 344 | movie['score'] = score; 345 | movie['num'] = num; 346 | movie['img'] = img; 347 | 348 | list.add(movie); 349 | print('---------------------'); 350 | //hdImgMap.addAll({movieId: imgUrl}); 351 | } 352 | 353 | setState(() { 354 | _newMovieList = list; 355 | }); 356 | }); 357 | } 358 | 359 | // 爬取周榜 360 | void getWeeklyData() { 361 | List> list = []; 362 | http.get('https://movie.douban.com/chart').then((http.Response response) { 363 | var document = parse(response.body.toString()); 364 | List newList = document 365 | .getElementById('listCont2') 366 | .getElementsByClassName('clearfix'); 367 | 368 | for (int i = 1; i < newList.length; i++) { 369 | Map movie = new Map(); 370 | 371 | var info = newList[i].getElementsByTagName('a')[0]; 372 | var rank = newList[i] 373 | .getElementsByTagName('span')[0] 374 | .getElementsByTagName('div')[0]; 375 | String url = info.attributes['href']; 376 | String ranking = rank.attributes['class']; 377 | String change = rank.text.trim(); 378 | String movieId = url.split('/')[4].trim(); 379 | 380 | print(info.text.trim()); 381 | print(url); 382 | print(ranking + change); 383 | movie['rankid'] = i.toString(); 384 | movie['title'] = info.text.trim(); 385 | movie['url'] = url; 386 | movie['rank'] = ranking; 387 | movie['change'] = change; 388 | movie['id'] = movieId; 389 | 390 | list.add(movie); 391 | print('-------------------------------------------'); 392 | } 393 | setState(() { 394 | _weeklyRankList = list; 395 | }); 396 | }); 397 | } 398 | 399 | //跳转到详情页 400 | toDetailPage(String movieId) { 401 | setState(() { 402 | Navigator.of(context).push(new MaterialPageRoute( 403 | builder: (BuildContext context) { 404 | return new MovieDetailPage( 405 | movieId: movieId, 406 | ); 407 | }, 408 | )); 409 | }); 410 | } 411 | } 412 | 413 | class PageDemo extends StatefulWidget { 414 | @override 415 | State createState() => new _PageDemoState(); 416 | } 417 | 418 | class _PageDemoState extends State { 419 | final PageController _pageController = new PageController(); 420 | double _currentPage = 0.0; 421 | 422 | @override 423 | Widget build(BuildContext context) => new Scaffold( 424 | body: new LayoutBuilder( 425 | builder: (context, constraints) => new NotificationListener( 426 | onNotification: (ScrollNotification note) { 427 | setState(() { 428 | _currentPage = _pageController.page; 429 | }); 430 | }, 431 | child: new PageView.custom( 432 | physics: const PageScrollPhysics( 433 | parent: const BouncingScrollPhysics()), 434 | controller: _pageController, 435 | childrenDelegate: new SliverChildBuilderDelegate( 436 | (context, index) => new _SimplePage( 437 | '$index', 438 | parallaxOffset: constraints.maxWidth / 439 | 2.0 * 440 | (index - _currentPage), 441 | ), 442 | childCount: 10, 443 | ), 444 | ), 445 | )), 446 | ); 447 | } 448 | 449 | class _SimplePage extends StatelessWidget { 450 | _SimplePage(this.data, {Key key, this.parallaxOffset = 0.0}) 451 | : super(key: key); 452 | 453 | final String data; 454 | final double parallaxOffset; 455 | 456 | @override 457 | Widget build(BuildContext context) => new Center( 458 | child: new Center( 459 | child: new Column( 460 | mainAxisSize: MainAxisSize.min, 461 | children: [ 462 | new Text( 463 | data, 464 | style: const TextStyle(fontSize: 60.0), 465 | ), 466 | new SizedBox(height: 40.0), 467 | new Transform( 468 | transform: 469 | new Matrix4.translationValues(parallaxOffset, 0.0, 0.0), 470 | child: const Text('Yet another line of text'), 471 | ), 472 | ], 473 | ), 474 | ), 475 | ); 476 | } 477 | -------------------------------------------------------------------------------- /lib/pages/hotlist_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:doubanmovie_flutter/model//MovieIntroList.dart'; 5 | import 'package:doubanmovie_flutter/pages/movie_detail_page.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:html/dom.dart' as dom; 8 | import 'package:html/parser.dart' show parse; 9 | import 'package:http/http.dart' as http; 10 | 11 | /// 首页-热门电影列表 12 | /// Desc:请求豆瓣热门电影列表接口,根据接口返回的电影列表数据,用爬虫方法获取高清的电影海报地址 13 | 14 | class HotListPage extends StatefulWidget { 15 | @override 16 | State createState() { 17 | return new HotListState(); 18 | } 19 | } 20 | 21 | class HotListState extends State { 22 | // 网络数据 23 | var _data; 24 | 25 | // 卡片数组 26 | List cards = []; 27 | int curPage = 0; 28 | 29 | // 存放高清电影海报的数组 30 | Map hdImgMap; 31 | 32 | @override 33 | void initState() { 34 | super.initState(); 35 | loadData(false); 36 | } 37 | 38 | Widget getListItem(int pos) { 39 | return new InkWell( 40 | onTap: () { 41 | toDetailPage(_data[pos].id, hdImgMap[_data[pos].id]); 42 | }, 43 | child: new Padding( 44 | padding: new EdgeInsets.only(left: 15.0, right: 15.0, bottom: 10.0), 45 | child: new AspectRatio( 46 | aspectRatio: 28.0 / 37.0, 47 | child: new Card( 48 | // 卡片 49 | clipBehavior: Clip.hardEdge, 50 | elevation: 5.0, 51 | shape: new RoundedRectangleBorder( 52 | // 圆角 53 | borderRadius: BorderRadius.all( 54 | Radius.circular(16.0), 55 | ), 56 | ), 57 | child: new Stack( 58 | fit: StackFit.expand, 59 | children: [ 60 | new Image.network(_data[pos].images.large, fit: BoxFit.fill), 61 | hdImgMap == null 62 | ? new Text('...') // 为空的时候什么都不显示 63 | : Image.network( 64 | // 不为空,加载高清图 65 | hdImgMap[_data[pos].id], 66 | fit: BoxFit.fill, 67 | ), 68 | new Positioned( 69 | left: 0.0, 70 | top: 0.0, 71 | child: Container( 72 | // 卡片上部半透明蒙层 73 | padding: new EdgeInsets.only( 74 | left: 15.0, top: 10.0, right: 500.0, bottom: 10.0), 75 | decoration: new BoxDecoration( 76 | gradient: new LinearGradient( 77 | begin: const FractionalOffset(0.0, 0.0), 78 | end: const FractionalOffset(0.0, 1.0), 79 | colors: [ 80 | Colors.grey.shade600.withOpacity(0.7), 81 | Colors.grey.shade600.withOpacity(0.1), 82 | //const Color(0xff000000), 83 | //const Color(0xff000000) 84 | ], 85 | ), 86 | ), 87 | child: new Column( 88 | mainAxisSize: MainAxisSize.min, 89 | crossAxisAlignment: CrossAxisAlignment.start, 90 | children: [ 91 | new Text( 92 | '评分:${_data[pos].rating.average}', 93 | style: new TextStyle( 94 | color: Colors.white, 95 | fontWeight: FontWeight.bold, 96 | fontSize: 14.0, 97 | ), 98 | ), 99 | new Text( 100 | _data[pos].title, 101 | style: new TextStyle( 102 | color: Colors.white, 103 | fontWeight: FontWeight.bold, 104 | fontSize: 30.0, 105 | decorationColor: Colors.red), 106 | ), 107 | ], 108 | ), 109 | ), 110 | ), 111 | ], 112 | ), 113 | ), 114 | ), 115 | ), 116 | ); 117 | } 118 | 119 | toDetailPage(String movieId, String imgUrl) { 120 | setState(() { 121 | Navigator.of(context).push(new MaterialPageRoute( 122 | builder: (BuildContext context) { 123 | return new MovieDetailPage( 124 | movieId: movieId, 125 | hdImgUrl: imgUrl, 126 | ); 127 | }, 128 | )); 129 | }); 130 | } 131 | 132 | Future _pullToRefresh() async { 133 | curPage = 1; 134 | loadData(false); 135 | return null; 136 | } 137 | 138 | // 爬虫方法获取高清的电影封面图 139 | void getHdImgCover() async { 140 | Map map = new Map(); 141 | await http.get('https://movie.douban.com/cinema/nowplaying/beijing/').then( 142 | (http.Response response) { 143 | new Future( 144 | () { 145 | var document = parse(response.body.toString()); 146 | List items = 147 | document.getElementsByClassName('list-item'); 148 | for (var item in items) { 149 | var url = item 150 | .getElementsByTagName('img')[0] 151 | .attributes['src'] 152 | .toString(); 153 | String movieId = item.attributes['data-subject'].toString(); 154 | 155 | RegExp exp = new RegExp('public\/p.+\.jpg'); 156 | String s = exp.firstMatch(url).group(0); 157 | 158 | String imgId = s.substring(8, 18); 159 | String imgUrl = 160 | "https://img1.doubanio.com/view/photo/l/public/p" + 161 | imgId + 162 | ".jpg"; 163 | print("获取到封面: " + imgUrl); 164 | map[movieId] = imgUrl; 165 | 166 | setState( 167 | () { 168 | hdImgMap = map; 169 | }, 170 | ); 171 | } 172 | }, 173 | ); 174 | }, 175 | ); 176 | } 177 | 178 | @override 179 | Widget build(BuildContext context) { 180 | if (_data == null) { 181 | return new Center( 182 | child: new CircularProgressIndicator(), 183 | ); 184 | } else { 185 | return new RefreshIndicator( 186 | child: new ListView.builder( 187 | itemCount: _data.length + 1, 188 | itemBuilder: (BuildContext context, int position) { 189 | if (position == 0) { 190 | return new HotListTitle(); 191 | } else { 192 | return getListItem(position - 1); 193 | } 194 | }, 195 | ), 196 | onRefresh: _pullToRefresh); 197 | } 198 | } 199 | 200 | // 加载豆瓣的数据 201 | void loadData(bool isLoadMore) { 202 | http 203 | .get('https://api.douban.com/v2/movie/in_theaters') 204 | .then((http.Response response) { 205 | JsonDecoder jsonDecoder = new JsonDecoder(); 206 | Map respMap = jsonDecoder.convert(response.body); 207 | // 解析数据 208 | MovieIntroList list = new MovieIntroList.fromJson(respMap); 209 | 210 | //cards = list.subjects; 211 | cards = respMap['subjects']; 212 | print("resp" + cards.toString()); 213 | setState(() { 214 | _data = list.subjects; 215 | }); 216 | 217 | getHdImgCover(); 218 | }); 219 | } 220 | } 221 | 222 | // 顶部标题 223 | class HotListTitle extends StatelessWidget { 224 | @override 225 | Widget build(BuildContext context) { 226 | return new Container( 227 | child: new Text( 228 | '首页', 229 | style: new TextStyle( 230 | color: Colors.black, fontSize: 36.0, fontWeight: FontWeight.bold), 231 | ), 232 | padding: new EdgeInsets.only(left: 15.0, bottom: 13.0, top: 30.0), 233 | ); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /lib/pages/more_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_webview_plugin/flutter_webview_plugin.dart'; 3 | 4 | /// 更多页面 5 | class MorePage extends StatefulWidget { 6 | @override 7 | State createState() { 8 | return new _MorePageState(); 9 | } 10 | } 11 | 12 | class _MorePageState extends State { 13 | @override 14 | Widget build(BuildContext context) { 15 | return new ListView( 16 | children: [ 17 | buildMorePageTitle(), 18 | buildHeaderSection(), 19 | buildButtonSection(), 20 | buildQqGroupSection(), 21 | buildThanksForApi(), 22 | ], 23 | ); 24 | } 25 | 26 | Widget buildHeaderSection() { 27 | return new Container( 28 | margin: new EdgeInsets.only(left: 15.0, right: 15.0), 29 | decoration: new BoxDecoration( 30 | border: new Border( 31 | top: new BorderSide(color: Colors.grey.shade200, width: 1.0), 32 | ), 33 | ), 34 | padding: new EdgeInsets.only(top: 15.0, bottom: 15.0), 35 | child: new Stack( 36 | children: [ 37 | new Container( 38 | height: 80.0, 39 | alignment: new FractionalOffset(0.0, 0.5), 40 | child: new Text( 41 | 'Flutter-豆瓣电影', 42 | style: new TextStyle( 43 | fontSize: 24.0, 44 | color: Colors.black, 45 | ), 46 | ), 47 | ), 48 | new Align( 49 | alignment: new FractionalOffset(1.0, 0.0), 50 | child: new Container( 51 | width: 80.0, 52 | height: 80.0, 53 | decoration: new BoxDecoration( 54 | image: new DecorationImage( 55 | image: new AssetImage('images/flutter_logo.png'), 56 | fit: BoxFit.cover, 57 | ), 58 | color: Colors.white, 59 | borderRadius: new BorderRadius.all(new Radius.circular(40.0)), 60 | border: new Border.all( 61 | color: Colors.grey.shade200, 62 | width: 1.0, 63 | ), 64 | ), 65 | ), 66 | ), 67 | ], 68 | ), 69 | ); 70 | } 71 | 72 | Widget buildButtonSection() { 73 | return new Container( 74 | height: 120.0, 75 | margin: new EdgeInsets.only(top: 5.0), 76 | padding: new EdgeInsets.only( 77 | left: 10.0, right: 10.0, top: 15.0, bottom: 15.0), 78 | color: Colors.grey.shade100, 79 | child: new Container( 80 | child: new Flex( 81 | direction: Axis.horizontal, 82 | children: [ 83 | new Flexible( 84 | flex: 1, 85 | child: new InkWell( 86 | onTap: () { 87 | onGithubClick(); 88 | }, 89 | child: new Container( 90 | constraints: new BoxConstraints.expand(), 91 | margin: new EdgeInsets.only(left: 5.0, right: 5.0), 92 | color: Colors.white, 93 | child: new Column( 94 | mainAxisAlignment: MainAxisAlignment.center, 95 | children: [ 96 | Image.asset( 97 | 'images/icon_github.png', 98 | width: 40.0, 99 | height: 40.0, 100 | ), 101 | new Container( 102 | padding: new EdgeInsets.only(top: 10.0), 103 | child: new Text( 104 | '开源地址', 105 | style: new TextStyle( 106 | color: Colors.black, 107 | fontSize: 13.0, 108 | ), 109 | ), 110 | ) 111 | ], 112 | ), 113 | ), 114 | )), 115 | new Flexible( 116 | flex: 1, 117 | child: new InkWell( 118 | onTap: () { 119 | onArticleClick(); 120 | }, 121 | child: new Container( 122 | constraints: new BoxConstraints.expand(), 123 | margin: new EdgeInsets.only(left: 5.0, right: 5.0), 124 | color: Colors.white, 125 | child: new Column( 126 | mainAxisAlignment: MainAxisAlignment.center, 127 | children: [ 128 | Image.asset( 129 | 'images/icon_blog.png', 130 | width: 40.0, 131 | height: 40.0, 132 | ), 133 | new Container( 134 | padding: new EdgeInsets.only(top: 10.0), 135 | child: new Text( 136 | '文章', 137 | style: new TextStyle( 138 | color: Colors.black, 139 | fontSize: 13.0, 140 | ), 141 | ), 142 | ) 143 | ], 144 | ), 145 | ), 146 | ), 147 | ), 148 | new Flexible( 149 | flex: 1, 150 | child: new InkWell( 151 | onTap: () { 152 | onWxClick(); 153 | }, 154 | child: new Container( 155 | constraints: new BoxConstraints.expand(), 156 | margin: new EdgeInsets.only(left: 5.0, right: 5.0), 157 | color: Colors.white, 158 | child: new Column( 159 | mainAxisAlignment: MainAxisAlignment.center, 160 | children: [ 161 | Image.asset( 162 | 'images/icon_wx.png', 163 | width: 40.0, 164 | height: 40.0, 165 | ), 166 | new Container( 167 | padding: new EdgeInsets.only(top: 10.0), 168 | child: new Text( 169 | '微信公众号', 170 | style: new TextStyle( 171 | color: Colors.black, 172 | fontSize: 13.0, 173 | ), 174 | ), 175 | ) 176 | ], 177 | ), 178 | ), 179 | ), 180 | ), 181 | ], 182 | ), 183 | )); 184 | } 185 | 186 | Widget buildQqGroupSection() { 187 | return new InkWell( 188 | onTap: () { 189 | onQQGroupClick(); 190 | }, 191 | child: new Container( 192 | padding: new EdgeInsets.only( 193 | left: 15.0, right: 15.0, top: 15.0, bottom: 15.0), 194 | margin: new EdgeInsets.only(top: 15.0), 195 | decoration: new BoxDecoration(color: Colors.grey.shade100), 196 | child: new Row( 197 | children: [ 198 | Image.asset( 199 | 'images/icon_qq.png', 200 | width: 25.0, 201 | height: 25.0, 202 | ), 203 | new Container( 204 | margin: new EdgeInsets.only(left: 15.0), 205 | child: new Text( 206 | 'Flutter 技术讨论群', 207 | style: new TextStyle(fontSize: 15.0), 208 | ), 209 | ), 210 | ], 211 | ), 212 | ), 213 | ); 214 | } 215 | 216 | Widget buildThanksForApi() { 217 | return new Container( 218 | padding: 219 | new EdgeInsets.only(left: 15.0, right: 15.0, top: 15.0, bottom: 15.0), 220 | margin: new EdgeInsets.only(top: 5.0), 221 | decoration: new BoxDecoration(color: Colors.grey.shade100), 222 | child: new Row( 223 | children: [ 224 | Image.asset( 225 | 'images/icon_api.png', 226 | width: 25.0, 227 | height: 25.0, 228 | ), 229 | new Container( 230 | margin: new EdgeInsets.only(left: 15.0), 231 | child: new Text( 232 | '感谢豆瓣提供的Api', 233 | style: new TextStyle(fontSize: 15.0), 234 | ), 235 | ), 236 | ], 237 | ), 238 | ); 239 | } 240 | 241 | Widget buildMorePageTitle() { 242 | return new Container( 243 | child: new Text( 244 | '更多', 245 | style: new TextStyle( 246 | color: Colors.black, fontSize: 34.0, fontWeight: FontWeight.bold), 247 | ), 248 | padding: new EdgeInsets.only(top: 30.0, left: 15.0, bottom: 20.0), 249 | ); 250 | } 251 | 252 | void onGithubClick() { 253 | setState(() { 254 | Navigator.of(context).push(new MaterialPageRoute( 255 | builder: (BuildContext context) { 256 | return new WebviewScaffold( 257 | url: "https://github.com/zcoderr/Flutter_Douban", 258 | appBar: new AppBar( 259 | backgroundColor: Colors.lightBlueAccent, 260 | title: new Text("Flutter版豆瓣电影"), 261 | ), 262 | ); 263 | }, 264 | )); 265 | }); 266 | } 267 | 268 | void onArticleClick() {} 269 | 270 | void onWxClick() {} 271 | 272 | void onQQGroupClick() {} 273 | } 274 | -------------------------------------------------------------------------------- /lib/pages/movie_detail_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:ui' as ui; 3 | 4 | import 'package:doubanmovie_flutter/CustomView.dart'; 5 | import 'package:doubanmovie_flutter/model//MovieDetail.dart'; 6 | import 'package:doubanmovie_flutter/net/DateSource.dart'; 7 | import 'package:doubanmovie_flutter/palette.dart'; 8 | import 'package:flutter/material.dart'; 9 | import 'package:http/http.dart' as http; 10 | 11 | ///电影详情页 12 | // ignore: must_be_immutable 13 | class MovieDetailPage extends StatefulWidget { 14 | // 电影ID 15 | String movieId; 16 | 17 | // 电影高清海报图url 18 | String hdImgUrl; 19 | 20 | MovieDetailPage({Key key, this.movieId, this.hdImgUrl}) : super(key: key); 21 | 22 | @override 23 | State createState() { 24 | return new MovieDetailPageState(movieId, hdImgUrl); 25 | } 26 | } 27 | 28 | class MovieDetailPageState extends State { 29 | String _movieId; 30 | String hdImgUrl; 31 | MovieDetail movieDetail; 32 | Color _titleBarColor; 33 | 34 | MovieDetailPageState(this._movieId, this.hdImgUrl); 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | loadData(); 40 | DataSource.getWeeklyData(); 41 | } 42 | 43 | getImageAndPalette() async { 44 | //通过Flutter插件获取图片主色调 45 | Palette palette = 46 | await PaletteLib.getPaletteWithUrl(movieDetail.images.large); 47 | //print(palette.darkVibrant.color.toString()); 48 | 49 | if (!mounted) return; 50 | 51 | setState(() { 52 | if (palette.vibrant == null) { 53 | _titleBarColor = Colors.teal; 54 | } else { 55 | _titleBarColor = palette.vibrant.color; 56 | } 57 | }); 58 | } 59 | 60 | @override 61 | Widget build(BuildContext context) { 62 | if (movieDetail != null && _titleBarColor != null) { 63 | return new Scaffold( 64 | body: CustomScrollView( 65 | slivers: [ 66 | // titleBar 67 | new SliverAppBar( 68 | pinned: true, 69 | elevation: 0.0, 70 | backgroundColor: _titleBarColor, 71 | actions: [ 72 | new IconButton( 73 | icon: new Icon(Icons.share), 74 | tooltip: 'Open shopping cart', 75 | onPressed: () { 76 | // handle the press 77 | }, 78 | ), 79 | ], 80 | expandedHeight: 0.0, 81 | centerTitle: true, 82 | title: new Text( 83 | movieDetail.title, 84 | style: new TextStyle( 85 | fontWeight: FontWeight.bold, 86 | ), 87 | ), 88 | ), 89 | new SliverList( 90 | delegate: new SliverChildListDelegate( 91 | [ 92 | _blurHeaderSection( 93 | hdImgUrl == null ? movieDetail.images.large : hdImgUrl), 94 | _basicInfoSection(), 95 | _summarySection(), 96 | _actorListSection(), 97 | ], 98 | ), 99 | ), 100 | ], 101 | ), 102 | ); 103 | } 104 | // 加载状态样式 105 | return new Scaffold( 106 | body: new Center( 107 | child: new CircularProgressIndicator(), 108 | ), 109 | ); 110 | } 111 | 112 | // new SliverList( 113 | // delegate: new SliverChildBuilderDelegate( 114 | // (BuildContext context, int index) { 115 | // return getActorListItem(index); 116 | // }, 117 | // childCount: movieDetail.casts.length, 118 | // ), 119 | // ), 120 | 121 | void loadData() { 122 | //获取电影详情数据 123 | http 124 | .get('https://api.douban.com/v2/movie/subject/' + _movieId) 125 | .then((http.Response response) { 126 | JsonDecoder jsonDecoder = new JsonDecoder(); 127 | var _movieDetailMap = jsonDecoder.convert(response.body); 128 | setState(() { 129 | movieDetail = new MovieDetail.fromJson(_movieDetailMap); 130 | }); 131 | getImageAndPalette(); 132 | }); 133 | } 134 | 135 | // Header 布局 136 | Widget _blurHeaderSection(String imgUrl) { 137 | return new Stack( 138 | children: [ 139 | // 底部背景图 140 | new Image.network( 141 | movieDetail.images.large, 142 | fit: BoxFit.fill, 143 | width: 500.0, 144 | height: 500.0, 145 | ), 146 | // 毛玻璃效果浮层 147 | new BackdropFilter( 148 | filter: new ui.ImageFilter.blur(sigmaX: 7.0, sigmaY: 7.0), 149 | child: new Container( 150 | padding: new EdgeInsets.only(top: 0.0), 151 | width: 500.0, 152 | height: 500.0, 153 | decoration: 154 | new BoxDecoration(color: Colors.grey.shade900.withOpacity(0.0)), 155 | // 居中的海报图 156 | child: new Center( 157 | child: new Image.network( 158 | imgUrl, 159 | width: 270, 160 | height: 400, 161 | fit: BoxFit.cover, 162 | ), 163 | ), 164 | ), 165 | ), 166 | ], 167 | ); 168 | } 169 | 170 | // 基本信息区域 171 | Widget _basicInfoSection() { 172 | return new Padding( 173 | padding: new EdgeInsets.only(left: 15.0, right: 15.0, top: 5.0), 174 | child: new Row( 175 | children: [ 176 | // weight=1 177 | new Expanded( 178 | child: new Align( 179 | alignment: new FractionalOffset(0.0, 0.0), 180 | child: new Column( 181 | // 左对齐 182 | crossAxisAlignment: CrossAxisAlignment.start, 183 | children: [ 184 | // 电影标题 185 | new Text( 186 | movieDetail.title, 187 | style: new TextStyle( 188 | fontSize: 20.0, 189 | letterSpacing: 1.5, 190 | fontWeight: FontWeight.bold), 191 | ), 192 | // 电影标签 193 | new Padding( 194 | padding: new EdgeInsets.only(top: 5.0, bottom: 2.0), 195 | child: new Text( 196 | getGenres(), 197 | style: new TextStyle( 198 | color: Colors.grey, 199 | fontSize: 11.0, 200 | letterSpacing: 1.5, 201 | ), 202 | ), 203 | ), 204 | new Text( 205 | '原名:' + movieDetail.original_title, 206 | style: new TextStyle(color: Colors.grey, fontSize: 11.0), 207 | ), 208 | ], 209 | ), 210 | ), 211 | ), 212 | new Container( 213 | width: 100.0, 214 | height: 100.0, 215 | alignment: new FractionalOffset(1.0, 0.0), 216 | child: new Card( 217 | elevation: 5.0, 218 | shape: new RoundedRectangleBorder( 219 | borderRadius: BorderRadius.all( 220 | Radius.circular(0.0), 221 | ), 222 | ), 223 | child: new Center( 224 | child: new Column( 225 | mainAxisSize: MainAxisSize.min, 226 | children: [ 227 | new Text( 228 | '豆瓣评分', 229 | style: 230 | new TextStyle(color: Colors.grey, fontSize: 10.0), 231 | ), 232 | new Padding( 233 | padding: new EdgeInsets.only(top: 2.0), 234 | child: new Text( 235 | movieDetail.rating.average.toString(), 236 | style: new TextStyle( 237 | color: Colors.black, 238 | fontSize: 20.0, 239 | fontWeight: FontWeight.bold), 240 | ), 241 | ), 242 | new ScoreView( 243 | 10.0, 244 | 2.0, 245 | new Size(100.0, 20.0), 246 | movieDetail.rating.average, 247 | ), 248 | new Text( 249 | movieDetail.ratings_count.toString() + "人", 250 | style: 251 | new TextStyle(color: Colors.grey, fontSize: 10.0), 252 | ), 253 | ], 254 | ), 255 | )), 256 | ) 257 | ], 258 | ), 259 | ); 260 | } 261 | 262 | String getGenres() { 263 | StringBuffer sb = new StringBuffer(); 264 | sb.write(movieDetail.year); 265 | 266 | for (int i = 0; i < movieDetail.genres.length; i++) { 267 | sb.write('/' + movieDetail.genres[i]); 268 | } 269 | return sb.toString(); 270 | } 271 | 272 | //演员列表区域 273 | _actorListSection() { 274 | return new SingleChildScrollView( 275 | scrollDirection: Axis.horizontal, 276 | child: new Padding( 277 | padding: new EdgeInsets.only(left: 15.0, top: 20.0, bottom: 30.0), 278 | child: new Column( 279 | crossAxisAlignment: CrossAxisAlignment.start, 280 | children: [ 281 | new Text( 282 | '影人', 283 | style: new TextStyle(color: Colors.grey, fontSize: 12.0), 284 | ), 285 | new Row( 286 | children: buildActorItems(), 287 | ), 288 | ], 289 | ), 290 | )); 291 | } 292 | 293 | // 演员列表 294 | buildActorItems() { 295 | List actorItems = []; 296 | actorItems.add(getDirectorsListItem()); 297 | for (int i = 0; i < movieDetail.casts.length; i++) { 298 | actorItems.add(getActorListItem(i)); 299 | } 300 | return actorItems; 301 | } 302 | 303 | // 演员列表Item 304 | Widget getActorListItem(int pos) { 305 | return new Column( 306 | children: [ 307 | new Padding( 308 | padding: new EdgeInsets.only( 309 | top: 10.0, left: 12.0, right: 12.0, bottom: 5.0), 310 | child: Image.network( 311 | movieDetail.casts[pos].avatars == null 312 | ? "" 313 | : movieDetail.casts[pos].avatars.large, 314 | width: 70.0, 315 | height: 100.0, 316 | ), 317 | ), 318 | new Text(movieDetail.casts[pos].name), 319 | ], 320 | ); 321 | } 322 | 323 | // 导演Item 324 | Widget getDirectorsListItem() { 325 | return new Column( 326 | children: [ 327 | new Padding( 328 | padding: new EdgeInsets.only( 329 | top: 10.0, left: 6.0, right: 6.0, bottom: 5.0), 330 | child: Image.network( 331 | movieDetail.directors[0].avatars == null 332 | ? "" 333 | : movieDetail.directors[0].avatars.large, 334 | width: 70.0, 335 | height: 100.0, 336 | ), 337 | ), 338 | new LimitedBox( 339 | maxWidth: 70.0, 340 | child: new Text( 341 | movieDetail.directors[0].name, 342 | style: new TextStyle(fontSize: 12.0), 343 | overflow: TextOverflow.ellipsis, 344 | ), 345 | ), 346 | new Text( 347 | '导演', 348 | style: new TextStyle(color: Colors.grey, fontSize: 12.0), 349 | ), 350 | ], 351 | ); 352 | } 353 | 354 | // 电影简介区域 355 | Widget _summarySection() { 356 | return new Padding( 357 | padding: new EdgeInsets.only(left: 15.0, right: 15.0, top: 10.0), 358 | child: Column( 359 | crossAxisAlignment: CrossAxisAlignment.start, 360 | mainAxisSize: MainAxisSize.min, 361 | children: [ 362 | new Text( 363 | '简介', 364 | style: new TextStyle(color: Colors.grey, fontSize: 12.0), 365 | ), 366 | new Padding( 367 | padding: new EdgeInsets.only(top: 7.0), 368 | child: new Text( 369 | movieDetail.summary, 370 | style: new TextStyle( 371 | letterSpacing: 2.0, 372 | ), 373 | ), 374 | ) 375 | ], 376 | ), 377 | ); 378 | } 379 | } 380 | 381 | class ImgBlurPaint extends CustomPainter { 382 | ImgBlurPaint(); 383 | 384 | @override 385 | void paint(Canvas canvas, Size size) { 386 | Paint paint = new Paint(); 387 | 388 | ui.ImageFilter.blur( 389 | sigmaX: 0.0, 390 | sigmaY: 0.0, 391 | ); 392 | } 393 | 394 | @override 395 | bool shouldRepaint(CustomPainter oldDelegate) { 396 | return true; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /lib/pages/upcoming_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class UpComningPage extends StatelessWidget { 4 | @override 5 | Widget build(BuildContext context) { 6 | return new Scaffold( 7 | body: new Text('ss'), 8 | ); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/palette.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:ui'; 3 | 4 | import 'package:flutter/services.dart'; 5 | 6 | class PaletteLib { 7 | static const MethodChannel _channel = 8 | const MethodChannel('channel:com.postmuseapp.designer/palette'); 9 | 10 | static Future getPalette(String path) async { 11 | Map result = 12 | await _channel.invokeMethod('getPalette', {'path': path}); 13 | return Palette.fromJson(result); 14 | } 15 | 16 | static Future getPaletteWithByte(ByteData image) async { 17 | Map result = 18 | await _channel.invokeMethod('getPaletteWihtByte', {'iamge': image}); 19 | return Palette.fromJson(result); 20 | } 21 | 22 | static Future getPaletteWithUrl(String url) async { 23 | Map result = 24 | await _channel.invokeMethod('getPaletteWithUrl', {'url': url}); 25 | return Palette.fromJson(result); 26 | } 27 | 28 | static Future getPP() async { 29 | Map result = await _channel.invokeMethod('getPP'); 30 | return PP.fromJson(result); 31 | } 32 | } 33 | 34 | class PP { 35 | final String result; 36 | 37 | PP.fromJson(Map map) : result = map['result']; 38 | 39 | Map toJson() => {'result': result}; 40 | } 41 | 42 | class Palette { 43 | final PaletteSwatch vibrant, 44 | darkVibrant, 45 | lightVibrant, 46 | muted, 47 | darkMuted, 48 | lightMuted; 49 | final List swatches; 50 | 51 | Palette.fromJson(Map map) 52 | : vibrant = map['vibrant'] == null 53 | ? null 54 | : new PaletteSwatch.fromJson(map['vibrant']), 55 | darkVibrant = map['darkVibrant'] == null 56 | ? null 57 | : new PaletteSwatch.fromJson(map['darkVibrant']), 58 | lightVibrant = map['lightVibrant'] == null 59 | ? null 60 | : new PaletteSwatch.fromJson(map['lightVibrant']), 61 | muted = map['muted'] == null 62 | ? null 63 | : new PaletteSwatch.fromJson(map['muted']), 64 | darkMuted = map['darkMuted'] == null 65 | ? null 66 | : new PaletteSwatch.fromJson(map['darkMuted']), 67 | lightMuted = map['lightMuted'] == null 68 | ? null 69 | : new PaletteSwatch.fromJson(map['lightMuted']), 70 | swatches = map['swatches'] == null 71 | ? null 72 | : (map['swatches'] as List) 73 | .map((json) => new PaletteSwatch.fromJson(json)) 74 | .toList(growable: false); 75 | 76 | Map toJson() => { 77 | "vibrant": vibrant == null ? null : vibrant.toJson(), 78 | "darkVibrant": darkVibrant == null ? null : darkVibrant.toJson(), 79 | "lightVibrant": lightVibrant == null ? null : lightVibrant.toJson(), 80 | "muted": muted == null ? null : muted.toJson(), 81 | "darkMuted": darkMuted == null ? null : darkMuted.toJson(), 82 | "lightMuted": lightMuted == null ? null : lightMuted.toJson(), 83 | "swatches": swatches == null 84 | ? null 85 | : swatches.map((swatch) => swatch.toJson()).toList(growable: false), 86 | }; 87 | 88 | @override 89 | String toString() { 90 | return 'Palette{\n' 91 | ' vibrant: $vibrant, \n' 92 | ' darkVibrant: $darkVibrant, \n' 93 | ' lightVibrant: $lightVibrant, \n' 94 | ' muted: $muted, \n' 95 | ' darkMuted: $darkMuted, \n' 96 | ' lightMuted: $lightMuted\n' 97 | ' swatches(${swatches.length}): $swatches\n' 98 | '}'; 99 | } 100 | } 101 | 102 | class PaletteSwatch { 103 | final Color color, titleTextColor, bodyTextColor; 104 | final int population; 105 | 106 | const PaletteSwatch( 107 | {this.color, this.titleTextColor, this.bodyTextColor, this.population}); 108 | 109 | PaletteSwatch.fromJson(Map map) 110 | : color = map['color'] == null ? null : new Color(map['color']), 111 | titleTextColor = map['titleTextColor'] == null 112 | ? null 113 | : new Color(map['titleTextColor']), 114 | bodyTextColor = map['bodyTextColor'] == null 115 | ? null 116 | : new Color(map['bodyTextColor']), 117 | population = map['population']; 118 | 119 | Map toJson() => { 120 | "color": color == null ? null : color.value, 121 | "titleTextColor": titleTextColor == null ? null : titleTextColor.value, 122 | "bodyTextColor": bodyTextColor == null ? null : bodyTextColor.value, 123 | "population": population, 124 | }; 125 | 126 | @override 127 | String toString() { 128 | return 'PaletteSwatch{color: $color, titleTextColor: $titleTextColor, bodyTextColor: $bodyTextColor, population: $population}'; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/utils/NetUtils.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart' as http; 2 | 3 | class NetUtils { 4 | static void get(String url, Function callback, 5 | {Map params, Function errorCallback}) async { 6 | if (params != null && params.isNotEmpty) { 7 | StringBuffer sb = new StringBuffer("?"); 8 | params.forEach((key, value) { 9 | sb.write("$key" + "=" + "$value" + "&"); 10 | }); 11 | String paramStr = sb.toString(); 12 | paramStr = paramStr.substring(0, paramStr.length - 1); 13 | url += paramStr; 14 | } 15 | // print("$url"); 16 | try { 17 | http.Response res = await http.get(url); 18 | if (callback != null) { 19 | callback(res.body); 20 | } 21 | } catch (exception) { 22 | if (errorCallback != null) { 23 | errorCallback(exception); 24 | } 25 | } 26 | } 27 | 28 | static void post(String url, Function callback, 29 | {Map params, Function errorCallback}) async { 30 | try { 31 | http.Response res = await http.post(url, body: params); 32 | if (callback != null) { 33 | callback(res.body); 34 | } 35 | } catch (e) { 36 | if (errorCallback != null) { 37 | errorCallback(e); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: doubanmovie_flutter 2 | description: A new Flutter project. 3 | 4 | # The following defines the version and build number for your application. 5 | # A version number is three numbers separated by dots, like 1.2.43 6 | # followed by an optional build number separated by a +. 7 | # Both the version and the builder number may be overridden in flutter 8 | # build by specifying --build-name and --build-number, respectively. 9 | # Read more about versioning at semver.org. 10 | version: 1.0.0+1 11 | 12 | dependencies: 13 | flutter: 14 | sdk: flutter 15 | 16 | # The following adds the Cupertino Icons font to your application. 17 | # Use with the CupertinoIcons class for iOS style icons. 18 | cupertino_icons: ^0.1.2 19 | 20 | flutter_webview_plugin: ^0.3.5 21 | image_picker: ^0.6.0+9 22 | # fluttertoast: "^2.0.3" 23 | shared_preferences: ^0.5.3+1 24 | event_bus: ^1.1.0 25 | #barcode_scan: ^0.0.4 26 | json_annotation: ^2.4.0 27 | http: ^0.12.0+2 28 | html: ^0.14.0+2 29 | 30 | dev_dependencies: 31 | build_runner: ^1.5.2 32 | json_serializable: ^3.0.0 33 | 34 | 35 | # For information on the generic Dart part of this file, see the 36 | # following page: https://www.dartlang.org/tools/pub/pubspec 37 | 38 | # The following section is specific to Flutter. 39 | flutter: 40 | 41 | # The following line ensures that the Material Icons font is 42 | # included with your application, so that you can use the icons in 43 | # the material Icons class. 44 | uses-material-design: true 45 | 46 | # To add assets to your application, add an assets section, like this: 47 | assets: 48 | - images/icon_hot_normal.png 49 | - images/icon_hot_selected.png 50 | - images/icon_upcoming_normal.png 51 | - images/icon_upcoming_selected.png 52 | - images/icon_explore_normal.png 53 | - images/icon_explore_selected.png 54 | - images/icon_more_selected.png 55 | - images/icon_more_normal.png 56 | - images/icon_ranking_up.png 57 | - images/icon_ranking_down.png 58 | - images/flutter_logo.png 59 | - images/icon_blog.png 60 | - images/icon_github.png 61 | - images/icon_qq.png 62 | - images/icon_wx.png 63 | - images/icon_api.png 64 | 65 | 66 | # An image asset can refer to one or more resolution-specific "variants", see 67 | # https://flutter.io/assets-and-images/#resolution-aware. 68 | 69 | # For details regarding adding assets from package dependencies, see 70 | # https://flutter.io/assets-and-images/#from-packages 71 | 72 | # To add custom fonts to your application, add a fonts section here, 73 | # in this "flutter" section. Each entry in this list should have a 74 | # "family" key with the font family name, and a "fonts" key with a 75 | # list giving the asset and other descriptors for the font. For 76 | # example: 77 | # fonts: 78 | # - family: Schyler 79 | # fonts: 80 | # - asset: fonts/Schyler-Regular.ttf 81 | # - asset: fonts/Schyler-Italic.ttf 82 | # style: italic 83 | # - family: Trajan Pro 84 | # fonts: 85 | # - asset: fonts/TrajanPro.ttf 86 | # - asset: fonts/TrajanPro_Bold.ttf 87 | # weight: 700 88 | # 89 | # For details regarding fonts from package dependencies, 90 | # see https://flutter.io/custom-fonts/#from-packages 91 | -------------------------------------------------------------------------------- /screenshots/IMG_0309.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0309.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0310.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0310.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0311.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0311.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0312.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0312.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0313.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0313.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0319.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0319.PNG -------------------------------------------------------------------------------- /screenshots/IMG_0320.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcoderr/Flutter_Douban/e7d3d595292bca5197376aa42509cb35d8c136eb/screenshots/IMG_0320.PNG --------------------------------------------------------------------------------