diff --git a/.gitignore b/.gitignore index 8551635f..a8342be8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,43 @@ -.DS_Store +android/google-play-service-account.json +android/key.properties + +_Store .atom/ -.idea +.idea/ .vscode/ + .packages .pub/ -build/ -ios/.generated/ -packages +.dart_tool/ pubspec.lock + +Podfile +Podfile.lock +Pods/ +.symlinks/ +**/Flutter/App.framework/ +**/Flutter/Flutter.framework/ +**/Flutter/Generated.xcconfig +**/Flutter/flutter_assets/ +ServiceDefinitions.json +xcuserdata/ + +local.properties +.gradle/ +gradlew +gradlew.bat +gradle-wrapper.jar +*.iml + +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m +GeneratedPluginRegistrant.java +build/ .flutter-plugins -lib/tmdb_config.dart -ios/Podfile.lock +.idea/workspace.xml +.firebaserc +.firebase/ +.firebase-debug.log +.DS_Store -android/google-play-service-account.json -android/key.properties \ No newline at end of file +core/lib/src/tmdb_config.dart diff --git a/README.md b/README.md index c7b06072..bab228df 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,82 @@ -# inKino - a showtime browser for Finnkino cinemas +# inKino - a multiplatform Dart project with code sharing between Flutter and web - + ## What is inKino? -inKino is a minimal app for browsing movies and showtimes for [Finnkino](https://finnkino.fi/) cinemas. It's made with [Flutter](https://flutter.io/), uses [flutter_redux](https://github.com/brianegan/flutter_redux), and has an [extensive set of unit and widget tests](https://github.com/roughike/inKino/tree/master/test). It also has smooth transition animations and handles offline use cases gracefully. +inKino is a _multiplatform_ Dart app for browsing movies and showtimes for Finnkino cinemas. -While I built inKino for my own needs, it is also intented to showcase good app structure and a clean, well-organized Flutter codebase. +InKino showcases Redux, has an extensive set of automated tests and **40% code sharing between Flutter and web**. +The Android & iOS apps are made with a single [Flutter](http://flutter.io) codebase. The progressive web app is made with [AngularDart](https://webdev.dartlang.org/angular). +This project is generally something that I believe is a good example of a multiplatform Dart project. -The source code is **100% Dart**, and everything resides in the [/lib](https://github.com/roughike/inKino/tree/master/lib) folder. +I plan on doing a full article series on multiplatform Dart stuff, so you might want to [check out my blog](https://iirokrankka.com) and subscribe to it.
-Get it on Google Play Get it on the App Store +Get it on Google Play +Get it on the App Store +Get it on the App Store
+## Folder structure + +There's three different folders. Each of them is a Dart project. + +* **core**: contains the pure Dart business logic, such API communication, Redux, XML parsing, sanitization, i18n, models and utilities. +It also has a great test coverage. +* **mobile**: this is the Flutter project. It imports **core**, and it's a 100% shared codebase for the native Android & iOS apps that go on app stores. +* **web**: the AngularDart progressive web app. Also imports **core**, and it's the thing that is live at https://inkino.app. + +To work on these projects, open each one of them in an editor of your choice. + +For example, if you want to do a new feature and you do it for the Flutter project first, you'd open both **core** and **mobile** in separate editor windows. +To clarify, you'd do `File -> Open...` for core and then `File -> Open...` again for mobile. + +## Development environment setup + +* [Install Dart for the web](https://webdev.dartlang.org/tools/sdk#install). The customized Dart version Flutter ships with is not suitable for web development. +* Install [webdev](https://webdev.dartlang.org/tools/webdev) by running `pub global activate webdev`. This requires that you ran your Dart installation properly and Dart is part of your PATH. +* Install an IDE. You can't go wrong with [WebStorm](https://webdev.dartlang.org/tools/webstorm). If that doesn't tickle your fancy, [there are other options too](https://www.dartlang.org/tools#ides). +* Install the Dart plugin for your IDE. + +Finally, if you haven't already, [install Flutter](https://flutter.io/docs/get-started/install). +And the Flutter plugin for your IDE. +At the time of being, inKino is built with **Flutter 0.10.2**. + +If you don't like IDEs, [you can apparently use Emacs or Vim too](https://news.ycombinator.com/item?id=16822780). + ## Building the project -While it should work on older versions as well, the project is currently built with Flutter `v0.2.3` on the `beta` channel. +### Renaming the TMDB configuration file -It won't build unless you add the following file manually: +You don't need a TMDB API key, but the actor images won't load without it. -**lib/tmdb_config.dart** +If you try to build the project straight away, you'll get an error complaining that a tmdb_config.dart file is missing. +To resolve that, run this on your terminal in the project root: -```dart -class TMDBConfig { - /// The TMDB API is mostly used for loading actor avatars. - /// - /// Having a real API key here is optional; if this doesn't - /// contain the real API key, the app will still work, but - /// the actor avatars won't load. - static final String apiKey = ''; -} +```bash +cd core/lib/src && mv tmdb_config.dart.sample tmdb_config.dart && cd ../../.. ``` +**OR** + +If you don't trust in random bash scripts copied from the Internet, you can just rename the `tmdb_config.dart.sample` to `tmdb_config.dart` manually. + +### Building from source + +First, ensure that you followed the "Development environment setup" section above. + +* To run the **web project**, first run `pub get` initially, and then `webdev serve` in the root of the web project. +* To run the **Flutter project**, open it in your editor and click the play button, or run `flutter run` on your terminal. + ## Contributing -Contributions are welcome! However, if it's going to be a major change, please create an issue first. +Contributions are welcome! +However, if it's going to be a major change, please create an issue first. +Before starting to work on something, please comment on a specific issue and say you'd like to work on it. + +## Thanks + +Special thanks to [Olli Haataja](https://www.linkedin.com/in/olli-haataja-46b96b120/) for the design. + +Additional thanks for the initial release go to [Thibaud Colas](https://twitter.com/thibaud_colas), [Brian Egan](https://twitter.com/brianegan), [Alessandro Aime](https://twitter.com/aimealessandro) and [Juho Rautioaho](https://github.com/Jraut) for giving their extra pair of eyes for reviewing the source code. diff --git a/screenshots/app_store.png b/_screenshots/app_store.png similarity index 100% rename from screenshots/app_store.png rename to _screenshots/app_store.png diff --git a/_screenshots/event_details.png b/_screenshots/event_details.png new file mode 100644 index 00000000..f8c05371 Binary files /dev/null and b/_screenshots/event_details.png differ diff --git a/screenshots/google_play.png b/_screenshots/google_play.png similarity index 100% rename from screenshots/google_play.png rename to _screenshots/google_play.png diff --git a/_screenshots/launch_pwa.png b/_screenshots/launch_pwa.png new file mode 100644 index 00000000..47fce876 Binary files /dev/null and b/_screenshots/launch_pwa.png differ diff --git a/_screenshots/now_in_theaters.png b/_screenshots/now_in_theaters.png new file mode 100644 index 00000000..c3fc7e32 Binary files /dev/null and b/_screenshots/now_in_theaters.png differ diff --git a/_screenshots/showtimes.png b/_screenshots/showtimes.png new file mode 100644 index 00000000..30f8d574 Binary files /dev/null and b/_screenshots/showtimes.png differ diff --git a/android.iml b/android.iml deleted file mode 100644 index 462b903e..00000000 --- a/android.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/android/app/release/app-release.apk b/android/app/release/app-release.apk deleted file mode 100644 index 38503a9a..00000000 Binary files a/android/app/release/app-release.apk and /dev/null differ diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 13372aef..00000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index 9d82f789..00000000 --- a/android/gradlew +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; -esac - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" - -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index 8a0b282a..00000000 --- a/android/gradlew.bat +++ /dev/null @@ -1,90 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/core/analysis_options.yaml b/core/analysis_options.yaml new file mode 100644 index 00000000..97d4b470 --- /dev/null +++ b/core/analysis_options.yaml @@ -0,0 +1,14 @@ +analyzer: +# exclude: +# - path/to/excluded/files/** + +# Lint rules and documentation, see http://dart-lang.github.io/linter/lints +linter: + rules: + - cancel_subscriptions + - hash_and_equals + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - test_types_in_equals + - unrelated_type_equality_checks + - valid_regexps diff --git a/core/lib/core.dart b/core/lib/core.dart new file mode 100644 index 00000000..0f1d4fb9 --- /dev/null +++ b/core/lib/core.dart @@ -0,0 +1,27 @@ +export 'src/models/actor.dart'; +export 'src/models/content_descriptor.dart'; +export 'src/models/event.dart'; +export 'src/models/loading_status.dart'; +export 'src/models/show.dart'; +export 'src/models/show_cache.dart'; +export 'src/models/theater.dart'; + +export 'src/i18n/messages.dart'; +export 'src/i18n/inkino_messages_all.dart'; + +export 'src/networking/finnkino_api.dart'; + +export 'src/redux/_common/search.dart'; +export 'src/redux/_common/common_actions.dart'; +export 'src/redux/actor/actor_actions.dart'; +export 'src/redux/actor/actor_selectors.dart'; +export 'src/redux/app/app_state.dart'; +export 'src/redux/event/event_actions.dart'; +export 'src/redux/event/event_selectors.dart'; +export 'src/redux/show/show_actions.dart'; +export 'src/redux/show/show_selectors.dart'; +export 'src/redux/store.dart'; + +export 'src/viewmodels/theater_list_view_model.dart'; +export 'src/viewmodels/events_page_view_model.dart'; +export 'src/viewmodels/showtime_page_view_model.dart'; \ No newline at end of file diff --git a/core/lib/src/i18n/inkino_en.arb b/core/lib/src/i18n/inkino_en.arb new file mode 100644 index 00000000..ce7688c4 --- /dev/null +++ b/core/lib/src/i18n/inkino_en.arb @@ -0,0 +1,148 @@ +{ + "@@last_modified": "2018-11-03T15:02:36.546976", + "appName": "inKino", + "@appName": { + "type": "text", + "placeholders": {} + }, + "nowInTheaters": "Now in theaters", + "@nowInTheaters": { + "type": "text", + "placeholders": {} + }, + "showtimes": "Showtimes", + "@showtimes": { + "type": "text", + "placeholders": {} + }, + "comingSoon": "Coming soon", + "@comingSoon": { + "type": "text", + "placeholders": {} + }, + "oops": "Oops!", + "@oops": { + "type": "text", + "placeholders": {} + }, + "loadingMoviesError": "There was an error while\nloading movies.", + "@loadingMoviesError": { + "type": "text", + "placeholders": {} + }, + "tryAgain": "TRY AGAIN", + "@tryAgain": { + "type": "text", + "placeholders": {} + }, + "director": "Director", + "@director": { + "type": "text", + "placeholders": {} + }, + "storyline": "Storyline", + "@storyline": { + "type": "text", + "placeholders": {} + }, + "collapseStoryline": "touch to collapse", + "@collapseStoryline": { + "type": "text", + "placeholders": {} + }, + "expandStoryline": "touch to expand", + "@expandStoryline": { + "type": "text", + "placeholders": {} + }, + "cast": "Cast", + "@cast": { + "type": "text", + "placeholders": {} + }, + "gallery": "Gallery", + "@gallery": { + "type": "text", + "placeholders": {} + }, + "releaseDate": "Release date", + "@releaseDate": { + "type": "text", + "placeholders": {} + }, + "at": "at", + "@at": { + "type": "text", + "placeholders": {} + }, + "tickets": "Tickets", + "@tickets": { + "type": "text", + "placeholders": {} + }, + "allEmpty": "All empty!", + "@allEmpty": { + "type": "text", + "placeholders": {} + }, + "noMovies": "Didn't find any movies at\nall.", + "@noMovies": { + "type": "text", + "placeholders": {} + }, + "errorLoadingEvents": "Error loading events.", + "@errorLoadingEvents": { + "type": "text", + "placeholders": {} + }, + "noMoviesForToday": "Didn't find any movies\nabout to start for today. ¯\\_(ツ)_/¯", + "@noMoviesForToday": { + "type": "text", + "placeholders": {} + }, + "about": "About", + "@about": { + "type": "text", + "placeholders": {} + }, + "aboutInKino": "About inKino", + "@aboutInKino": { + "type": "text", + "placeholders": {} + }, + "gotIt": "Okay, got it!", + "@gotIt": { + "type": "text", + "placeholders": {} + }, + "aboutInKinoDescription": "inKino is the unofficial Finnkino client that is minimalistic, fast, and delightful to use.", + "@aboutInKinoDescription": { + "type": "text", + "placeholders": {} + }, + "appDevelopedWith": "The app was developed with", + "@appDevelopedWith": { + "type": "text", + "placeholders": {} + }, + "checkoutRepo": "and it's open source; check out the source code yourself from", + "@checkoutRepo": { + "type": "text", + "placeholders": {} + }, + "githubRepo": "the GitHub repo", + "@githubRepo": { + "type": "text", + "placeholders": {} + }, + "tmdbAttribution": "This product uses the TMDb API but is not endorsed or certified by TMDb.", + "@tmdbAttribution": { + "type": "text", + "placeholders": {} + }, + "searchHint": "Search movies & showtimes...", + "@searchHint": { + "type": "text", + "placeholders": {} + } +} \ No newline at end of file diff --git a/core/lib/src/i18n/inkino_fi.arb b/core/lib/src/i18n/inkino_fi.arb new file mode 100644 index 00000000..92b5e4bc --- /dev/null +++ b/core/lib/src/i18n/inkino_fi.arb @@ -0,0 +1,147 @@ +{ + "appName": "inKino", + "@appName": { + "type": "text", + "placeholders": {} + }, + "nowInTheaters": "Ohjelmistossa", + "@nowInTheaters": { + "type": "text", + "placeholders": {} + }, + "showtimes": "Näytösajat", + "@showtimes": { + "type": "text", + "placeholders": {} + }, + "comingSoon": "Tulossa", + "@comingSoon": { + "type": "text", + "placeholders": {} + }, + "oops": "Oho!", + "@oops": { + "type": "text", + "placeholders": {} + }, + "loadingMoviesError": "Ongelma elokuvien latauksessa.", + "@loadingMoviesError": { + "type": "text", + "placeholders": {} + }, + "tryAgain": "YRITÄ UUDELLEEN", + "@tryAgain": { + "type": "text", + "placeholders": {} + }, + "director": "Ohjaaja", + "@director": { + "type": "text", + "placeholders": {} + }, + "storyline": "Juoni", + "@storyline": { + "type": "text", + "placeholders": {} + }, + "collapseStoryline": "näytä enemmän", + "@collapseStoryline": { + "type": "text", + "placeholders": {} + }, + "expandStoryline": "näytä vähemmän", + "@expandStoryline": { + "type": "text", + "placeholders": {} + }, + "cast": "Näyttelijät", + "@cast": { + "type": "text", + "placeholders": {} + }, + "gallery": "Galleria", + "@gallery": { + "type": "text", + "placeholders": {} + }, + "releaseDate": "Julkaisupäivä", + "@releaseDate": { + "type": "text", + "placeholders": {} + }, + "at": "klo", + "@at": { + "type": "text", + "placeholders": {} + }, + "tickets": "Liput", + "@tickets": { + "type": "text", + "placeholders": {} + }, + "allEmpty": "Tyhjää täynnä!", + "@allEmpty": { + "type": "text", + "placeholders": {} + }, + "noMovies": "Elokuvia ei löytynyt.", + "@noMovies": { + "type": "text", + "placeholders": {} + }, + "errorLoadingEvents": "Elokuvia ladattaessa tapahtui virhe.", + "@errorLoadingEvents": { + "type": "text", + "placeholders": {} + }, + "noMoviesForToday": "Tälle päivälle ei löytynyt yhtään alkavia elokuvia. ¯ \\ _ (ツ) _ / ¯", + "@noMoviesForToday": { + "type": "text", + "placeholders": {} + }, + "about": "Tietoa", + "@about": { + "type": "text", + "placeholders": {} + }, + "aboutInKino": "Tietoa InKinosta", + "@aboutInKino": { + "type": "text", + "placeholders": {} + }, + "gotIt": "Selvä homma!", + "@gotIt": { + "type": "text", + "placeholders": {} + }, + "aboutInKinoDescription": "inKino on epävirallinen Finnkino-sovellus, joka on minimalistinen, nopea ja ihastuttava käyttää.", + "@aboutInKinoDescription": { + "type": "text", + "placeholders": {} + }, + "appDevelopedWith": "Sovellus on kehitetty käyttämällä", + "@appDevelopedWith": { + "type": "text", + "placeholders": {} + }, + "checkoutRepo": "ja sen lähdekoodi on julkisesti saatavilla", + "@checkoutRepo": { + "type": "text", + "placeholders": {} + }, + "githubRepo": "GitHubissa", + "@githubRepo": { + "type": "text", + "placeholders": {} + }, + "tmdbAttribution": "Tämä tuote käyttää TMDb:n rajapintaa, mutta sitä ei ole TMDb:n suosittelema tai sertifioima.", + "@tmdbAttribution": { + "type": "text", + "placeholders": {} + }, + "searchHint": "Hae elokuvia ja näytösaikoja...", + "@searchHint": { + "type": "text", + "placeholders": {} + } +} \ No newline at end of file diff --git a/core/lib/src/i18n/inkino_messages_all.dart b/core/lib/src/i18n/inkino_messages_all.dart new file mode 100644 index 00000000..42afe11f --- /dev/null +++ b/core/lib/src/i18n/inkino_messages_all.dart @@ -0,0 +1,61 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that looks up messages for specific locales by +// delegating to the appropriate library. + +import 'dart:async'; + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; +// ignore: implementation_imports +import 'package:intl/src/intl_helpers.dart'; + +import 'package:core/src/i18n/inkino_messages_en.dart' deferred as messages_en; +import 'package:core/src/i18n/inkino_messages_fi.dart' deferred as messages_fi; + +typedef Future LibraryLoader(); +Map _deferredLibraries = { + 'en': () => messages_en.loadLibrary(), + 'fi': () => messages_fi.loadLibrary(), +}; + +MessageLookupByLibrary _findExact(localeName) { + switch (localeName) { + case 'en': + return messages_en.messages; + case 'fi': + return messages_fi.messages; + default: + return null; + } +} + +/// User programs should call this before using [localeName] for messages. +Future initializeMessages(String localeName) async { + var availableLocale = Intl.verifiedLocale( + localeName, + (locale) => _deferredLibraries[locale] != null, + onFailure: (_) => null); + if (availableLocale == null) { + return new Future.value(false); + } + var lib = _deferredLibraries[availableLocale]; + await (lib == null ? new Future.value(false) : lib()); + initializeInternalMessageLookup(() => new CompositeMessageLookup()); + messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); + return new Future.value(true); +} + +bool _messagesExistFor(String locale) { + try { + return _findExact(locale) != null; + } catch (e) { + return false; + } +} + +MessageLookupByLibrary _findGeneratedMessagesFor(locale) { + var actualLocale = Intl.verifiedLocale(locale, _messagesExistFor, + onFailure: (_) => null); + if (actualLocale == null) return null; + return _findExact(actualLocale); +} diff --git a/core/lib/src/i18n/inkino_messages_en.dart b/core/lib/src/i18n/inkino_messages_en.dart new file mode 100644 index 00000000..7e7fd12a --- /dev/null +++ b/core/lib/src/i18n/inkino_messages_en.dart @@ -0,0 +1,52 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a en locale. All the +// messages from the main program should be duplicated here with the same +// function name. + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = new MessageLookup(); + +// ignore: unused_element +final _keepAnalysisHappy = Intl.defaultLocale; + +// ignore: non_constant_identifier_names +typedef MessageIfAbsent(String message_str, List args); + +class MessageLookup extends MessageLookupByLibrary { + get localeName => 'en'; + + final messages = _notInlinedMessages(_notInlinedMessages); + static _notInlinedMessages(_) => { + "about" : MessageLookupByLibrary.simpleMessage("About"), + "aboutInKino" : MessageLookupByLibrary.simpleMessage("About inKino"), + "aboutInKinoDescription" : MessageLookupByLibrary.simpleMessage("inKino is the unofficial Finnkino client that is minimalistic, fast, and delightful to use."), + "allEmpty" : MessageLookupByLibrary.simpleMessage("All empty!"), + "appDevelopedWith" : MessageLookupByLibrary.simpleMessage("The app was developed with"), + "appName" : MessageLookupByLibrary.simpleMessage("inKino"), + "at" : MessageLookupByLibrary.simpleMessage("at"), + "cast" : MessageLookupByLibrary.simpleMessage("Cast"), + "checkoutRepo" : MessageLookupByLibrary.simpleMessage("and it\'s open source; check out the source code yourself from"), + "collapseStoryline" : MessageLookupByLibrary.simpleMessage("touch to collapse"), + "comingSoon" : MessageLookupByLibrary.simpleMessage("Coming soon"), + "director" : MessageLookupByLibrary.simpleMessage("Director"), + "errorLoadingEvents" : MessageLookupByLibrary.simpleMessage("Error loading events."), + "expandStoryline" : MessageLookupByLibrary.simpleMessage("touch to expand"), + "gallery" : MessageLookupByLibrary.simpleMessage("Gallery"), + "githubRepo" : MessageLookupByLibrary.simpleMessage("the GitHub repo"), + "gotIt" : MessageLookupByLibrary.simpleMessage("Okay, got it!"), + "loadingMoviesError" : MessageLookupByLibrary.simpleMessage("There was an error while\nloading movies."), + "noMovies" : MessageLookupByLibrary.simpleMessage("Didn\'t find any movies at\nall."), + "noMoviesForToday" : MessageLookupByLibrary.simpleMessage("Didn\'t find any movies\nabout to start for today. ¯\\_(ツ)_/¯"), + "nowInTheaters" : MessageLookupByLibrary.simpleMessage("Now in theaters"), + "oops" : MessageLookupByLibrary.simpleMessage("Oops!"), + "releaseDate" : MessageLookupByLibrary.simpleMessage("Release date"), + "searchHint" : MessageLookupByLibrary.simpleMessage("Search movies & showtimes..."), + "showtimes" : MessageLookupByLibrary.simpleMessage("Showtimes"), + "storyline" : MessageLookupByLibrary.simpleMessage("Storyline"), + "tickets" : MessageLookupByLibrary.simpleMessage("Tickets"), + "tmdbAttribution" : MessageLookupByLibrary.simpleMessage("This product uses the TMDb API but is not endorsed or certified by TMDb."), + "tryAgain" : MessageLookupByLibrary.simpleMessage("TRY AGAIN") + }; +} diff --git a/core/lib/src/i18n/inkino_messages_fi.dart b/core/lib/src/i18n/inkino_messages_fi.dart new file mode 100644 index 00000000..beff92a5 --- /dev/null +++ b/core/lib/src/i18n/inkino_messages_fi.dart @@ -0,0 +1,52 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a fi locale. All the +// messages from the main program should be duplicated here with the same +// function name. + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = new MessageLookup(); + +// ignore: unused_element +final _keepAnalysisHappy = Intl.defaultLocale; + +// ignore: non_constant_identifier_names +typedef MessageIfAbsent(String message_str, List args); + +class MessageLookup extends MessageLookupByLibrary { + get localeName => 'fi'; + + final messages = _notInlinedMessages(_notInlinedMessages); + static _notInlinedMessages(_) => { + "about" : MessageLookupByLibrary.simpleMessage("Tietoa"), + "aboutInKino" : MessageLookupByLibrary.simpleMessage("Tietoa InKinosta"), + "aboutInKinoDescription" : MessageLookupByLibrary.simpleMessage("inKino on epävirallinen Finnkino-sovellus, joka on minimalistinen, nopea ja ihastuttava käyttää."), + "allEmpty" : MessageLookupByLibrary.simpleMessage("Tyhjää täynnä!"), + "appDevelopedWith" : MessageLookupByLibrary.simpleMessage("Sovellus on kehitetty käyttämällä"), + "appName" : MessageLookupByLibrary.simpleMessage("inKino"), + "at" : MessageLookupByLibrary.simpleMessage("klo"), + "cast" : MessageLookupByLibrary.simpleMessage("Näyttelijät"), + "checkoutRepo" : MessageLookupByLibrary.simpleMessage("ja sen lähdekoodi on julkisesti saatavilla"), + "collapseStoryline" : MessageLookupByLibrary.simpleMessage("näytä enemmän"), + "comingSoon" : MessageLookupByLibrary.simpleMessage("Tulossa"), + "director" : MessageLookupByLibrary.simpleMessage("Ohjaaja"), + "errorLoadingEvents" : MessageLookupByLibrary.simpleMessage("Elokuvia ladattaessa tapahtui virhe."), + "expandStoryline" : MessageLookupByLibrary.simpleMessage("näytä vähemmän"), + "gallery" : MessageLookupByLibrary.simpleMessage("Galleria"), + "githubRepo" : MessageLookupByLibrary.simpleMessage("GitHubissa"), + "gotIt" : MessageLookupByLibrary.simpleMessage("Selvä homma!"), + "loadingMoviesError" : MessageLookupByLibrary.simpleMessage("Ongelma elokuvien latauksessa."), + "noMovies" : MessageLookupByLibrary.simpleMessage("Elokuvia ei löytynyt."), + "noMoviesForToday" : MessageLookupByLibrary.simpleMessage("Tälle päivälle ei löytynyt yhtään alkavia elokuvia. ¯ \\ _ (ツ) _ / ¯"), + "nowInTheaters" : MessageLookupByLibrary.simpleMessage("Ohjelmistossa"), + "oops" : MessageLookupByLibrary.simpleMessage("Oho!"), + "releaseDate" : MessageLookupByLibrary.simpleMessage("Julkaisupäivä"), + "searchHint" : MessageLookupByLibrary.simpleMessage("Hae elokuvia ja näytösaikoja..."), + "showtimes" : MessageLookupByLibrary.simpleMessage("Näytösajat"), + "storyline" : MessageLookupByLibrary.simpleMessage("Juoni"), + "tickets" : MessageLookupByLibrary.simpleMessage("Liput"), + "tmdbAttribution" : MessageLookupByLibrary.simpleMessage("Tämä tuote käyttää TMDb:n rajapintaa, mutta sitä ei ole TMDb:n suosittelema tai sertifioima."), + "tryAgain" : MessageLookupByLibrary.simpleMessage("YRITÄ UUDELLEEN") + }; +} diff --git a/core/lib/src/i18n/messages.dart b/core/lib/src/i18n/messages.dart new file mode 100644 index 00000000..df4c1926 --- /dev/null +++ b/core/lib/src/i18n/messages.dart @@ -0,0 +1,75 @@ +import 'package:intl/intl.dart'; + +class Messages { + String get appName => Intl.message('inKino', name: 'appName'); + String get nowInTheaters => Intl.message( + 'Now in theaters', + name: 'nowInTheaters', + ); + String get showtimes => Intl.message( + 'Showtimes', + name: 'showtimes', + ); + String get comingSoon => Intl.message( + 'Coming soon', + name: 'comingSoon', + ); + + String get oops => Intl.message('Oops!', name: 'oops'); + String get loadingMoviesError => Intl.message( + 'There was an error while\nloading movies.', + name: 'loadingMoviesError', + ); + String get tryAgain => Intl.message('TRY AGAIN', name: 'tryAgain'); + + String get director => Intl.message('Director', name: 'director'); + String get storyline => Intl.message('Storyline', name: 'storyline'); + String get collapseStoryline => + Intl.message('touch to collapse', name: 'collapseStoryline'); + String get expandStoryline => + Intl.message('touch to expand', name: 'expandStoryline'); + String get cast => Intl.message('Cast', name: 'cast'); + String get gallery => Intl.message('Gallery', name: 'gallery'); + + String get releaseDate => Intl.message('Release date', name: 'releaseDate'); + String get at => Intl.message( + 'at', + name: 'at', + meaning: 'Means time. For example, "the meeting is at 6PM".', + ); + + String get tickets => Intl.message('Tickets', name: 'tickets'); + String get allEmpty => Intl.message('All empty!', name: 'allEmpty'); + String get noMovies => + Intl.message('Didn\'t find any movies at\nall.', name: 'noMovies'); + String get errorLoadingEvents => + Intl.message('Error loading events.', name: 'errorLoadingEvents'); + + String get noMoviesForToday => Intl.message( + 'Didn\'t find any movies\nabout to start for today. ¯\\_(ツ)_/¯', + name: 'noMoviesForToday', + ); + String get about => Intl.message('About', name: 'about'); + String get aboutInKino => Intl.message('About inKino', name: 'aboutInKino'); + String get gotIt => Intl.message('Okay, got it!', name: 'gotIt'); + String get aboutInKinoDescription => Intl.message( + 'inKino is the unofficial Finnkino client that is minimalistic, fast, and delightful to use.', + name: 'aboutInKinoDescription', + ); + String get appDevelopedWith => Intl.message( + 'The app was developed with', + name: 'appDevelopedWith', + ); + String get checkoutRepo => Intl.message( + 'and it\'s open source; check out the source code yourself from', + name: 'checkoutRepo', + ); + String get githubRepo => Intl.message('the GitHub repo', name: 'githubRepo'); + String get tmdbAttribution => Intl.message( + 'This product uses the TMDb API but is not endorsed or certified by TMDb.', + name: 'tmdbAttribution', + ); + + String get searchHint => + Intl.message('Search movies & showtimes...', name: 'searchHint'); +} diff --git a/lib/data/models/actor.dart b/core/lib/src/models/actor.dart similarity index 100% rename from lib/data/models/actor.dart rename to core/lib/src/models/actor.dart diff --git a/core/lib/src/models/content_descriptor.dart b/core/lib/src/models/content_descriptor.dart new file mode 100644 index 00000000..53f0125d --- /dev/null +++ b/core/lib/src/models/content_descriptor.dart @@ -0,0 +1,29 @@ +import 'package:meta/meta.dart'; + +class ContentDescriptor { + ContentDescriptor({ + @required this.name, + @required this.imageUrl, + }); + + final String name; + final String imageUrl; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ContentDescriptor && + runtimeType == other.runtimeType && + name == other.name && + imageUrl == other.imageUrl; + + @override + int get hashCode => + name.hashCode ^ + imageUrl.hashCode; + + @override + String toString() { + return 'ContentDescriptor{name: $name, imageUrl: $imageUrl}'; + } +} diff --git a/core/lib/src/models/event.dart b/core/lib/src/models/event.dart new file mode 100644 index 00000000..5f8a33e5 --- /dev/null +++ b/core/lib/src/models/event.dart @@ -0,0 +1,175 @@ +import 'package:meta/meta.dart'; + +import 'actor.dart'; +import 'content_descriptor.dart'; + +enum EventListType { + nowInTheaters, + comingSoon, +} + +class Event { + Event({ + this.id, + this.title, + this.originalTitle, + this.releaseDate, + this.ageRating, + this.ageRatingUrl, + this.genres, + this.directors, + this.actors, + this.lengthInMinutes, + this.shortSynopsis, + this.synopsis, + this.images, + this.contentDescriptors, + this.youtubeTrailers, + this.galleryImages, + }); + + final String id; + final String title; + final String originalTitle; + final DateTime releaseDate; + final String ageRating; + final String ageRatingUrl; + final String genres; + final List directors; + final String lengthInMinutes; + final String shortSynopsis; + final String synopsis; + final EventImageData images; + final List contentDescriptors; + final List youtubeTrailers; + final List galleryImages; + + String get director => + directors.firstWhere((e) => e != null, orElse: () => null); + List actors; + List get genresSeparated => genres.split(', '); + + bool get hasSynopsis => + (shortSynopsis != null && shortSynopsis.isNotEmpty) || + (synopsis != null && synopsis.isNotEmpty); + bool get hasMediumPortraitImage => images.portraitMedium != null; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Event && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + originalTitle == other.originalTitle && + releaseDate == other.releaseDate && + ageRating == other.ageRating && + ageRatingUrl == other.ageRatingUrl && + genres == other.genres && + directors == other.directors && + lengthInMinutes == other.lengthInMinutes && + shortSynopsis == other.shortSynopsis && + synopsis == other.synopsis && + images == other.images && + contentDescriptors == other.contentDescriptors && + youtubeTrailers == other.youtubeTrailers && + actors == other.actors; + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + originalTitle.hashCode ^ + releaseDate.hashCode ^ + ageRating.hashCode ^ + ageRatingUrl.hashCode ^ + genres.hashCode ^ + directors.hashCode ^ + lengthInMinutes.hashCode ^ + shortSynopsis.hashCode ^ + synopsis.hashCode ^ + images.hashCode ^ + contentDescriptors.hashCode ^ + youtubeTrailers.hashCode ^ + actors.hashCode; +} + +class GalleryImage { + GalleryImage({ + this.location, + this.thumbnailLocation, + }); + + final String location; + final String thumbnailLocation; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GalleryImage && + runtimeType == other.runtimeType && + location == other.location && + thumbnailLocation == other.thumbnailLocation; + + @override + int get hashCode => location.hashCode ^ thumbnailLocation.hashCode; +} + +class EventImageData { + EventImageData({ + @required this.portraitSmall, + @required this.portraitMedium, + @required this.portraitLarge, + @required this.landscapeSmall, + @required this.landscapeBig, + @required this.landscapeHd, + @required this.landscapeHd2, + }); + + final String portraitSmall; + final String portraitMedium; + final String portraitLarge; + final String landscapeSmall; + final String landscapeBig; + final String landscapeHd; + final String landscapeHd2; + + String get anyAvailableImage => + portraitSmall ?? + portraitMedium ?? + portraitLarge ?? + landscapeSmall ?? + landscapeBig; + + EventImageData.empty() + : portraitSmall = null, + portraitMedium = null, + portraitLarge = null, + landscapeSmall = null, + landscapeBig = null, + landscapeHd = null, + landscapeHd2 = null; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EventImageData && + runtimeType == other.runtimeType && + portraitSmall == other.portraitSmall && + portraitMedium == other.portraitMedium && + portraitLarge == other.portraitLarge && + landscapeSmall == other.landscapeSmall && + landscapeBig == other.landscapeBig && + landscapeHd == other.landscapeHd && + landscapeHd2 == other.landscapeHd2; + + @override + int get hashCode => + portraitSmall.hashCode ^ + portraitMedium.hashCode ^ + portraitLarge.hashCode ^ + landscapeSmall.hashCode ^ + landscapeBig.hashCode ^ + landscapeHd.hashCode ^ + landscapeHd2.hashCode; +} diff --git a/lib/data/loading_status.dart b/core/lib/src/models/loading_status.dart similarity index 86% rename from lib/data/loading_status.dart rename to core/lib/src/models/loading_status.dart index 3b280d8f..de82fbd4 100644 --- a/lib/data/loading_status.dart +++ b/core/lib/src/models/loading_status.dart @@ -1,4 +1,5 @@ enum LoadingStatus { + idle, loading, error, success, diff --git a/core/lib/src/models/show.dart b/core/lib/src/models/show.dart new file mode 100644 index 00000000..dbec00b7 --- /dev/null +++ b/core/lib/src/models/show.dart @@ -0,0 +1,69 @@ +import 'content_descriptor.dart'; +import 'event.dart'; + +class Show { + Show({ + this.id, + this.eventId, + this.title, + this.originalTitle, + this.ageRating, + this.ageRatingUrl, + this.url, + this.presentationMethod, + this.theaterAndAuditorium, + this.start, + this.end, + this.images, + this.contentDescriptors, + }); + + final String id; + final String eventId; + final String title; + final String originalTitle; + final String ageRating; + final String ageRatingUrl; + final String url; + final String presentationMethod; + final String theaterAndAuditorium; + final DateTime start; + final DateTime end; + final EventImageData images; + final List contentDescriptors; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Show && + runtimeType == other.runtimeType && + id == other.id && + eventId == other.eventId && + title == other.title && + originalTitle == other.originalTitle && + ageRating == other.ageRating && + ageRatingUrl == other.ageRatingUrl && + url == other.url && + presentationMethod == other.presentationMethod && + theaterAndAuditorium == other.theaterAndAuditorium && + start == other.start && + end == other.end && + images == other.images && + contentDescriptors == other.contentDescriptors; + + @override + int get hashCode => + id.hashCode ^ + eventId.hashCode ^ + title.hashCode ^ + originalTitle.hashCode ^ + ageRating.hashCode ^ + ageRatingUrl.hashCode ^ + url.hashCode ^ + presentationMethod.hashCode ^ + theaterAndAuditorium.hashCode ^ + start.hashCode ^ + end.hashCode ^ + images.hashCode ^ + contentDescriptors.hashCode; +} diff --git a/core/lib/src/models/show_cache.dart b/core/lib/src/models/show_cache.dart new file mode 100644 index 00000000..2f26923e --- /dev/null +++ b/core/lib/src/models/show_cache.dart @@ -0,0 +1,33 @@ +import 'package:core/src/redux/app/app_state.dart'; +import 'package:intl/intl.dart'; + +import 'theater.dart'; + +/// Used as a lookup key for caching showtimes. +/// +/// When we've already loaded showtimes for a specific theater on specific date, +/// we don't want to load them again. This pair class is used as a key in a Map +/// structure, and the values are the showtimes. +class DateTheaterPair { + static final ddMMyyyy = new DateFormat('dd.MM.yyyy'); + + DateTheaterPair(this.dateTime, this.theater); + + DateTheaterPair.fromState(AppState state) + : dateTime = state.showState.selectedDate, + theater = state.theaterState.currentTheater; + + final DateTime dateTime; + final Theater theater; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DateTheaterPair && + runtimeType == other.runtimeType && + dateTime == other.dateTime && + theater == other.theater; + + @override + int get hashCode => dateTime.hashCode ^ theater.hashCode; +} diff --git a/core/lib/src/models/theater.dart b/core/lib/src/models/theater.dart new file mode 100644 index 00000000..2db7012b --- /dev/null +++ b/core/lib/src/models/theater.dart @@ -0,0 +1,22 @@ +import 'package:meta/meta.dart'; + +class Theater { + Theater({ + @required this.id, + @required this.name, + }); + + final String id; + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Theater && + runtimeType == other.runtimeType && + id == other.id && + name == other.name; + + @override + int get hashCode => id.hashCode ^ name.hashCode; +} diff --git a/core/lib/src/networking/finnkino_api.dart b/core/lib/src/networking/finnkino_api.dart new file mode 100644 index 00000000..94f3204c --- /dev/null +++ b/core/lib/src/networking/finnkino_api.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/parsers/event_parser.dart'; +import 'package:core/src/parsers/show_parser.dart'; +import 'package:http/http.dart'; +import 'package:intl/intl.dart'; + +class FinnkinoApi { + static final ddMMyyyy = DateFormat('dd.MM.yyyy'); + static final enBaseUrl = 'https://www.finnkino.fi/en/xml'; + static final fiBaseUrl = 'https://www.finkino.fi/xml'; + + static bool useFinnish = false; + + FinnkinoApi(this.client); + final Client client; + + String get localizedPath => useFinnish ? '' : '/en'; + Uri get kScheduleBaseUrl => + Uri.https('www.finnkino.fi', '$localizedPath/xml/Schedule'); + Uri get kEventsBaseUrl => + Uri.https('www.finnkino.fi', '$localizedPath/xml/Events'); + + Future> getSchedule(Theater theater, DateTime date) async { + final dt = ddMMyyyy.format(date ?? new DateTime.now()); + final response = await client.get( + kScheduleBaseUrl.replace(queryParameters: { + 'area': theater.id, + 'dt': dt, + 'includeGallery': 'true', + }), + ); + + return ShowParser.parse(utf8.decode(response.bodyBytes)); + } + + Future> getNowInTheatersEvents(Theater theater) async { + final response = await client.get( + kEventsBaseUrl.replace(queryParameters: { + 'area': theater.id, + 'listType': 'NowInTheatres', + 'includeGallery': 'true', + }), + ); + + return EventParser.parse(utf8.decode(response.bodyBytes)); + } + + Future> getUpcomingEvents() async { + final response = await client.get( + kEventsBaseUrl.replace(queryParameters: { + 'listType': 'ComingSoon', + 'includeGallery': 'true', + }), + ); + + return EventParser.parse(utf8.decode(response.bodyBytes)); + } +} diff --git a/core/lib/src/networking/image_url_rewriter.dart b/core/lib/src/networking/image_url_rewriter.dart new file mode 100644 index 00000000..91cbeb32 --- /dev/null +++ b/core/lib/src/networking/image_url_rewriter.dart @@ -0,0 +1,24 @@ +final _finnkinoBaseUrl = RegExp(r'https?://media.finnkino.fi'); +const _imgixBaseUrl = 'https://inkino.imgix.net'; +const _imgixQueryParams = '?auto=format,compress'; + +final notYetRated = RegExp(r'.*not.*yet.*rated.*', caseSensitive: false); + +String rewriteImageUrl(String originalUrl) { + if (originalUrl == null) { + return null; + } + + if (originalUrl.contains(notYetRated)) { + /// Finnkino XML API might return a "Not yet rated" as an image url for a + /// content age rating image. And you might know that "Not yet rated" is not + /// a valid url. + originalUrl = originalUrl.replaceFirst( + notYetRated, + 'https://media.finnkino.fi/images/rating_large_Tulossa.png', + ); + } + + return originalUrl.replaceFirst(_finnkinoBaseUrl, _imgixBaseUrl) + + _imgixQueryParams; +} diff --git a/core/lib/src/networking/tmdb_api.dart b/core/lib/src/networking/tmdb_api.dart new file mode 100644 index 00000000..d7f31e39 --- /dev/null +++ b/core/lib/src/networking/tmdb_api.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/tmdb_config.dart'; +import 'package:http/http.dart'; + +/// If this has a red underline, it means that the lib/tmdb_config.dart file +/// is not present on the project. Refer to the README for instructions +/// on how to do so. + +class TMDBApi { + TMDBApi(this.client); + final Client client; + + static final String baseUrl = 'api.themoviedb.org'; + + Future> findAvatarsForActors( + Event event, List actors) async { + int movieId = await _findMovieId(event.originalTitle); + + if (movieId != null) { + return _getActorAvatars(movieId); + } + + return actors; + } + + Future _findMovieId(String movieTitle) async { + final searchUri = Uri.https(baseUrl, '3/search/movie', { + 'api_key': TMDBConfig.apiKey, + 'query': movieTitle, + }); + + final response = await client.get(searchUri); + Map movieSearchJson = + json.decode(utf8.decode(response.bodyBytes)); + final searchResults = + (movieSearchJson['results'] as List).cast>(); + + if (searchResults.isNotEmpty) { + return searchResults.first['id']; + } + + return null; + } + + Future> _getActorAvatars(int movieId) async { + final actorUri = Uri.https( + baseUrl, + '3/movie/$movieId/credits', + {'api_key': TMDBConfig.apiKey}, + ); + + final response = await client.get(actorUri); + Map movieActors = + json.decode(utf8.decode(response.bodyBytes)); + + return _parseActorAvatars( + (movieActors['cast'] as List).cast>()); + } + + List _parseActorAvatars(List> movieCast) { + final actorsWithAvatars = []; + + movieCast.forEach((Map castMember) { + String pp = castMember['profile_path']; + final profilePath = + pp != null ? 'https://image.tmdb.org/t/p/w200$pp' : null; + + actorsWithAvatars.add(Actor( + name: castMember['name'], + avatarUrl: profilePath, + )); + }); + + return actorsWithAvatars; + } +} diff --git a/core/lib/src/parsers/content_descriptor_parser.dart b/core/lib/src/parsers/content_descriptor_parser.dart new file mode 100644 index 00000000..aaa3e2a2 --- /dev/null +++ b/core/lib/src/parsers/content_descriptor_parser.dart @@ -0,0 +1,19 @@ +import 'package:core/src/models/content_descriptor.dart'; +import 'package:core/src/networking/image_url_rewriter.dart'; +import 'package:core/src/utils/xml_utils.dart'; +import 'package:xml/xml.dart'; + +class ContentDescriptorParser { + static List parse(Iterable roots) { + if (roots == null) { + return []; + } + + return roots.first.findElements('ContentDescriptor').map((element) { + return ContentDescriptor( + name: tagContentsOrNull(element, 'Name'), + imageUrl: rewriteImageUrl(tagContentsOrNull(element, 'ImageURL')), + ); + }).toList(); + } +} diff --git a/core/lib/src/parsers/event_parser.dart b/core/lib/src/parsers/event_parser.dart new file mode 100644 index 00000000..bc43dad9 --- /dev/null +++ b/core/lib/src/parsers/event_parser.dart @@ -0,0 +1,145 @@ +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/parsers/content_descriptor_parser.dart'; +import 'package:core/src/parsers/gallery_parser.dart'; +import 'package:core/src/networking/image_url_rewriter.dart'; +import 'package:core/src/utils/event_name_cleaner.dart'; +import 'package:core/src/utils/xml_utils.dart'; +import 'package:xml/xml.dart' as xml; + +class EventParser { + static List parse(String xmlString) { + final document = xml.parse(xmlString); + final events = document.findAllElements('Event'); + + return events.map((node) { + final title = tagContents(node, 'Title'); + final originalTitle = tagContents(node, 'OriginalTitle'); + + return Event( + id: tagContents(node, 'ID'), + title: EventNameCleaner.cleanup(title), + originalTitle: EventNameCleaner.cleanup(originalTitle), + releaseDate: + _parseReleaseDate(tagContentsOrNull(node, 'dtLocalRelease')), + ageRating: tagContentsOrNull(node, 'Rating'), + ageRatingUrl: + rewriteImageUrl(tagContentsOrNull(node, 'RatingImageUrl')), + genres: tagContents(node, 'Genres'), + directors: _parseDirectors(node.findAllElements('Director')), + actors: _parseActors(node.findAllElements('Actor')), + lengthInMinutes: tagContents(node, 'LengthInMinutes'), + shortSynopsis: tagContents(node, 'ShortSynopsis'), + synopsis: tagContents(node, 'Synopsis'), + images: EventImageDataParser.parse(node.findElements('Images')), + contentDescriptors: ContentDescriptorParser.parse( + node.findElements('ContentDescriptors')), + youtubeTrailers: _parseTrailers(node.findAllElements('EventVideo')), + galleryImages: GalleryParser.parse(node.findElements('Gallery')), + ); + }).toList(); + } + + static DateTime _parseReleaseDate(String rawDate) { + try { + return DateTime.parse(rawDate); + } catch (e) { + return null; + } + } + + static List _parseDirectors(Iterable nodes) { + return nodes.map((node) { + final first = tagContents(node, 'FirstName'); + final last = tagContents(node, 'LastName'); + + return '$first $last'; + }).toList(); + } + + static List _parseActors(Iterable nodes) { + return nodes.map((node) { + final first = tagContents(node, 'FirstName'); + final last = tagContents(node, 'LastName'); + + return Actor(name: '$first $last'); + }).toList(); + } + + static List _parseTrailers(Iterable nodes) { + return nodes.map((node) { + return 'https://youtube.com/watch?v=' + tagContents(node, 'Location'); + }).toList(); + } +} + +class EventImageDataParser { + static EventImageData parse(Iterable roots) { + if (roots == null || roots.isEmpty) { + return EventImageData.empty(); + } + + final root = roots.first; + final landscapeBig = + rewriteImageUrl(tagContentsOrNull(root, 'EventLargeImageLandscape')); + + return EventImageData( + portraitSmall: + rewriteImageUrl(tagContentsOrNull(root, 'EventSmallImagePortrait')), + portraitMedium: + rewriteImageUrl(tagContentsOrNull(root, 'EventMediumImagePortrait')), + portraitLarge: + rewriteImageUrl(tagContentsOrNull(root, 'EventLargeImagePortrait')), + landscapeSmall: + rewriteImageUrl(tagContentsOrNull(root, 'EventSmallImageLandscape')), + landscapeBig: landscapeBig, + landscapeHd: _getHdImageUrl(landscapeBig), + landscapeHd2: _getHdImageUrl2(landscapeBig), + ); + } + + + /// + /// This hacky hack only exists because Finnkino API doesn't return HD + /// landscape images without also increasing the response size by 47,4% on + /// average. Yes, it really does that by including bunch of other unnecessary + /// images. + /// + /// In order to circumvent this, we "calculate" the HD landscape image url from + /// the large landscape url. + /// + /// For example, the url: + /// + /// https://media.finnkino.fi/1012/Event_11881/landscape_large/BookClub_670_kke.jpg + /// + /// converted to landscape HD url becomes: + /// + /// https://media.finnkino.fi//1012/Event_11881/landscape_hd/BookClub_1920_kke.jpg + /// + /// OR + /// + /// https://media.finnkino.fi//1012/Event_11881/landscape_hd/BookClub_1920_kke_.jpg + /// + /// Yep. That's right. It could be either one of those. So we just try loading the other + /// and if it fails, we try loading the second one. + /// + /// The one with the "_" is more common, but the other one appears too. + /// + /// TODO: This is ugly and I should feel ugly. Make this more efficient and + /// pretty. And DRY. + /// FIXME. please. + /// + static final _regex = RegExp(r'_670([^.]*)'); + + static String _getHdImageUrl(String bigUrl) { + return bigUrl + ?.replaceFirst('landscape_large', 'landscape_hd') + ?.replaceFirstMapped(_regex, (match) => '_1920${match.group(1)}_'); + } + + static String _getHdImageUrl2(String bigUrl) { + return bigUrl + ?.replaceFirst('landscape_large', 'landscape_hd') + ?.replaceFirstMapped(_regex, (match) => '_1920${match.group(1)}'); + } +} diff --git a/core/lib/src/parsers/gallery_parser.dart b/core/lib/src/parsers/gallery_parser.dart new file mode 100644 index 00000000..21675ede --- /dev/null +++ b/core/lib/src/parsers/gallery_parser.dart @@ -0,0 +1,20 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/networking/image_url_rewriter.dart'; +import 'package:core/src/utils/xml_utils.dart'; +import 'package:xml/xml.dart'; + +class GalleryParser { + static List parse(Iterable roots) { + if (roots == null || roots.isEmpty) { + return []; + } + + return roots.first.findElements('GalleryImage').map((node) { + return GalleryImage( + thumbnailLocation: + rewriteImageUrl(tagContents(node, 'ThumbnailLocation')), + location: rewriteImageUrl(tagContents(node, 'Location')), + ); + }).toList(); + } +} diff --git a/core/lib/src/parsers/show_parser.dart b/core/lib/src/parsers/show_parser.dart new file mode 100644 index 00000000..78319a27 --- /dev/null +++ b/core/lib/src/parsers/show_parser.dart @@ -0,0 +1,37 @@ +import 'package:core/src/models/show.dart'; +import 'package:core/src/parsers/content_descriptor_parser.dart'; +import 'package:core/src/parsers/event_parser.dart'; +import 'package:core/src/networking/image_url_rewriter.dart'; +import 'package:core/src/utils/event_name_cleaner.dart'; +import 'package:core/src/utils/xml_utils.dart'; +import 'package:xml/xml.dart' as xml; + +class ShowParser { + static List parse(String xmlString) { + final document = xml.parse(xmlString); + final shows = document.findAllElements('Show'); + + return shows.map((node) { + final title = tagContents(node, 'Title'); + final originalTitle = tagContents(node, 'OriginalTitle'); + + return Show( + id: tagContents(node, 'ID'), + eventId: tagContents(node, 'EventID'), + title: EventNameCleaner.cleanup(title), + originalTitle: EventNameCleaner.cleanup(originalTitle), + ageRating: tagContentsOrNull(node, 'Rating'), + ageRatingUrl: + rewriteImageUrl(tagContentsOrNull(node, 'RatingImageUrl')), + url: tagContents(node, 'ShowURL'), + presentationMethod: tagContents(node, 'PresentationMethod'), + theaterAndAuditorium: tagContents(node, 'TheatreAndAuditorium'), + start: DateTime.parse(tagContents(node, 'dttmShowStart')), + end: DateTime.parse(tagContents(node, 'dttmShowEnd')), + images: EventImageDataParser.parse(node.findElements('Images')), + contentDescriptors: ContentDescriptorParser.parse( + node.findElements('ContentDescriptors')), + ); + }).toList(); + } +} diff --git a/lib/data/models/theater.dart b/core/lib/src/parsers/theater_parser.dart similarity index 55% rename from lib/data/models/theater.dart rename to core/lib/src/parsers/theater_parser.dart index b48793df..8ee6c641 100644 --- a/lib/data/models/theater.dart +++ b/core/lib/src/parsers/theater_parser.dart @@ -1,45 +1,35 @@ -import 'package:meta/meta.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/utils/xml_utils.dart'; import 'package:xml/xml.dart' as xml; -import 'package:inkino/utils/xml_utils.dart'; final RegExp _nameExpr = new RegExp(r'([A-Z])([A-Z]+)'); -class Theater { +class TheaterParser { /// Entirely redundant theater, which isn't actually even a theater. /// The API returns this as "Valitse alue/teatteri", which means "choose a /// theater". Thanks Finnkino. static const String kChooseTheaterId = '1029'; - Theater({ - @required this.id, - @required this.name, - }); + static List parse(String xmlString) { + final document = xml.parse(xmlString); + final theaters = document.findAllElements('TheatreArea'); - final String id; - final String name; - - static List parseAll(String xmlString) { - var theaters = []; - var document = xml.parse(xmlString); - - document.findAllElements('TheatreArea').forEach((node) { - var id = tagContents(node, 'ID'); + return theaters.map((node) { + final id = tagContents(node, 'ID'); var normalizedName = _normalize(tagContents(node, 'Name')); if (id == kChooseTheaterId) { normalizedName = 'All theaters'; } - theaters.add(new Theater( + return Theater( id: id, name: normalizedName, - )); - }); - - return theaters; + ); + }).toList(); } - static _normalize(String text) { + static String _normalize(String text) { return text.replaceAllMapped(_nameExpr, (match) { return '${match.group(1)}${match.group(2).toLowerCase()}'; }); diff --git a/assets/preloaded_data/theaters.xml b/core/lib/src/preloaded_data.dart similarity index 95% rename from assets/preloaded_data/theaters.xml rename to core/lib/src/preloaded_data.dart index 64a66ea0..0e44bef1 100644 --- a/assets/preloaded_data/theaters.xml +++ b/core/lib/src/preloaded_data.dart @@ -1,4 +1,5 @@ - +class PreloadedData { + static const String theaters = ''' 1029 @@ -80,4 +81,5 @@ 1022 Turku: KINOPALATSI - \ No newline at end of file +'''; +} diff --git a/lib/redux/common_actions.dart b/core/lib/src/redux/_common/common_actions.dart similarity index 70% rename from lib/redux/common_actions.dart rename to core/lib/src/redux/_common/common_actions.dart index 3a982a68..3cf8a054 100644 --- a/lib/redux/common_actions.dart +++ b/core/lib/src/redux/_common/common_actions.dart @@ -1,6 +1,6 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/theater.dart'; +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/theater.dart'; class InitAction {} @@ -14,6 +14,8 @@ class InitCompleteAction { final Theater selectedTheater; } +class FetchComingSoonEventsIfNotLoadedAction {} + class ChangeCurrentTheaterAction { ChangeCurrentTheaterAction(this.selectedTheater); final Theater selectedTheater; @@ -24,4 +26,4 @@ class UpdateActorsForEventAction { final Event event; final List actors; -} \ No newline at end of file +} diff --git a/core/lib/src/redux/_common/search.dart b/core/lib/src/redux/_common/search.dart new file mode 100644 index 00000000..ad794872 --- /dev/null +++ b/core/lib/src/redux/_common/search.dart @@ -0,0 +1,12 @@ +class SearchQueryChangedAction { + SearchQueryChangedAction(this.query); + final String query; +} + +String searchQueryReducer(String searchQuery, dynamic action) { + if (action is SearchQueryChangedAction) { + return action.query; + } + + return searchQuery; +} diff --git a/lib/redux/app/app_actions.dart b/core/lib/src/redux/actor/actor_actions.dart similarity index 60% rename from lib/redux/app/app_actions.dart rename to core/lib/src/redux/actor/actor_actions.dart index 7d677d1b..ba8063c7 100644 --- a/lib/redux/app/app_actions.dart +++ b/core/lib/src/redux/actor/actor_actions.dart @@ -1,10 +1,5 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; - -class SearchQueryChangedAction { - SearchQueryChangedAction(this.query); - final String query; -} +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; class FetchActorAvatarsAction { FetchActorAvatarsAction(this.event); @@ -19,4 +14,4 @@ class ActorsUpdatedAction { class ReceivedActorAvatarsAction { ReceivedActorAvatarsAction(this.actors); final List actors; -} \ No newline at end of file +} diff --git a/core/lib/src/redux/actor/actor_middleware.dart b/core/lib/src/redux/actor/actor_middleware.dart new file mode 100644 index 00000000..4879de68 --- /dev/null +++ b/core/lib/src/redux/actor/actor_middleware.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:core/src/networking/tmdb_api.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/actor/actor_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:redux/redux.dart'; + +class ActorMiddleware extends MiddlewareClass { + ActorMiddleware(this.tmdbApi); + final TMDBApi tmdbApi; + + @override + Future call( + Store store, dynamic action, NextDispatcher next) async { + next(action); + + if (action is FetchActorAvatarsAction) { + next(ActorsUpdatedAction(action.event.actors)); + + try { + final actorsWithAvatars = await tmdbApi.findAvatarsForActors( + action.event, + action.event.actors, + ); + + // TMDB API might have a more comprehensive list of actors than the + // Finnkino API, so we update the event with the actors we get from + // the TMDB API. + next(UpdateActorsForEventAction(action.event, actorsWithAvatars)); + next(ReceivedActorAvatarsAction(actorsWithAvatars)); + } catch (e) { + // We don't need to handle this. If fetching actor avatars + // fails, we don't care: the UI just simply won't display + // any actor avatars and falls back to placeholder icons + // instead. + } + } + } +} diff --git a/core/lib/src/redux/actor/actor_reducer.dart b/core/lib/src/redux/actor/actor_reducer.dart new file mode 100644 index 00000000..01de4d86 --- /dev/null +++ b/core/lib/src/redux/actor/actor_reducer.dart @@ -0,0 +1,34 @@ +import 'package:core/src/models/actor.dart'; +import 'package:core/src/redux/actor/actor_actions.dart'; + +Map actorReducer(Map state, dynamic action) { + if (action is ActorsUpdatedAction) { + return _updateActors(state, action); + } else if (action is ReceivedActorAvatarsAction) { + return _updateActorAvatars(state, action); + } + + return state; +} + +Map _updateActors(Map state, dynamic action) { + final actors = {}..addAll(state); + action.actors.forEach((Actor actor) { + actors.putIfAbsent(actor.name, () => Actor(name: actor.name)); + }); + + return actors; +} + +Map _updateActorAvatars( + Map state, dynamic action) { + final actorsWithAvatars = {}..addAll(state); + action.actors.forEach((Actor actor) { + actorsWithAvatars[actor.name] = Actor( + name: actor.name, + avatarUrl: actor.avatarUrl, + ); + }); + + return actorsWithAvatars; +} diff --git a/lib/redux/app/app_selectors.dart b/core/lib/src/redux/actor/actor_selectors.dart similarity index 54% rename from lib/redux/app/app_selectors.dart rename to core/lib/src/redux/actor/actor_selectors.dart index 57d419af..2aab42c9 100644 --- a/lib/redux/app/app_selectors.dart +++ b/core/lib/src/redux/actor/actor_selectors.dart @@ -1,6 +1,6 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/redux/app/app_state.dart'; +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/redux/app/app_state.dart'; List actorsForEventSelector(AppState state, Event event) { return state.actorsByName.values diff --git a/core/lib/src/redux/app/app_reducer.dart b/core/lib/src/redux/app/app_reducer.dart new file mode 100644 index 00000000..fe2fa0c0 --- /dev/null +++ b/core/lib/src/redux/app/app_reducer.dart @@ -0,0 +1,17 @@ + +import 'package:core/src/redux/_common/search.dart'; +import 'package:core/src/redux/actor/actor_reducer.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_reducer.dart'; +import 'package:core/src/redux/show/show_reducer.dart'; +import 'package:core/src/redux/theater/theater_reducer.dart'; + +AppState appReducer(AppState state, dynamic action) { + return new AppState( + searchQuery: searchQueryReducer(state.searchQuery, action), + actorsByName: actorReducer(state.actorsByName, action), + theaterState: theaterReducer(state.theaterState, action), + showState: showReducer(state.showState, action), + eventState: eventReducer(state.eventState, action), + ); +} \ No newline at end of file diff --git a/lib/redux/app/app_state.dart b/core/lib/src/redux/app/app_state.dart similarity index 62% rename from lib/redux/app/app_state.dart rename to core/lib/src/redux/app/app_state.dart index 96df7fef..2bf91c5b 100644 --- a/lib/redux/app/app_state.dart +++ b/core/lib/src/redux/app/app_state.dart @@ -1,7 +1,7 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/redux/event/event_state.dart'; -import 'package:inkino/redux/show/show_state.dart'; -import 'package:inkino/redux/theater/theater_state.dart'; +import 'package:core/src/models/actor.dart'; +import 'package:core/src/redux/event/event_state.dart'; +import 'package:core/src/redux/show/show_state.dart'; +import 'package:core/src/redux/theater/theater_state.dart'; import 'package:meta/meta.dart'; @immutable @@ -21,12 +21,12 @@ class AppState { final EventState eventState; factory AppState.initial() { - return new AppState( + return AppState( searchQuery: null, actorsByName: {}, - theaterState: new TheaterState.initial(), - showState: new ShowState.initial(), - eventState: new EventState.initial(), + theaterState: TheaterState.initial(), + showState: ShowState.initial(), + eventState: EventState.initial(), ); } @@ -37,7 +37,7 @@ class AppState { ShowState showState, EventState eventState, }) { - return new AppState( + return AppState( searchQuery: searchQuery ?? this.searchQuery, actorsByName: actorsByName ?? this.actorsByName, theaterState: theaterState ?? this.theaterState, @@ -49,13 +49,13 @@ class AppState { @override bool operator ==(Object other) => identical(this, other) || - other is AppState && - runtimeType == other.runtimeType && - searchQuery == other.searchQuery && - actorsByName == other.actorsByName && - theaterState == other.theaterState && - showState == other.showState && - eventState == other.eventState; + other is AppState && + runtimeType == other.runtimeType && + searchQuery == other.searchQuery && + actorsByName == other.actorsByName && + theaterState == other.theaterState && + showState == other.showState && + eventState == other.eventState; @override int get hashCode => diff --git a/core/lib/src/redux/event/event_actions.dart b/core/lib/src/redux/event/event_actions.dart new file mode 100644 index 00000000..c6b28c81 --- /dev/null +++ b/core/lib/src/redux/event/event_actions.dart @@ -0,0 +1,28 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:meta/meta.dart'; + +class RefreshEventsAction { + RefreshEventsAction(this.type); + final EventListType type; +} + +class RequestingEventsAction { + RequestingEventsAction(this.type); + final EventListType type; +} + +class ReceivedInTheatersEventsAction { + ReceivedInTheatersEventsAction(this.events); + final List events; +} + +class ReceivedComingSoonEventsAction { + ReceivedComingSoonEventsAction(this.events); + final List events; +} + +class ErrorLoadingEventsAction { + ErrorLoadingEventsAction(this.type); + final EventListType type; +} diff --git a/core/lib/src/redux/event/event_middleware.dart b/core/lib/src/redux/event/event_middleware.dart new file mode 100644 index 00000000..293c0f4f --- /dev/null +++ b/core/lib/src/redux/event/event_middleware.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/networking/finnkino_api.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_actions.dart'; +import 'package:redux/redux.dart'; + +class EventMiddleware extends MiddlewareClass { + EventMiddleware(this.api); + final FinnkinoApi api; + + @override + Future call( + Store store, dynamic action, NextDispatcher next) async { + next(action); + + final theater = _determineTheater(action, store); + + if (action is InitCompleteAction) { + await _fetchNowPlayingEvents(theater, next); + } else if (action is RefreshEventsAction) { + await _refreshEvents(theater, action, next); + } else if (action is ChangeCurrentTheaterAction) { + await _fetchAllEvents(theater, next); + } else if (action is FetchComingSoonEventsIfNotLoadedAction) { + if (store.state.eventState.comingSoonStatus == LoadingStatus.idle) { + await _fetchComingSoonEvents(next); + } + } + } + + Future _fetchAllEvents(Theater theater, NextDispatcher next) async { + await _fetchNowPlayingEvents(theater, next); + return _fetchComingSoonEvents(next); + } + + Future _fetchNowPlayingEvents( + Theater theater, NextDispatcher next) async { + if (theater != null) { + next(RequestingEventsAction(EventListType.nowInTheaters)); + + try { + final inTheatersEvents = await api.getNowInTheatersEvents(theater); + next(ReceivedInTheatersEventsAction(inTheatersEvents)); + } catch (e) { + next(ErrorLoadingEventsAction(EventListType.nowInTheaters)); + } + } + } + + Future _fetchComingSoonEvents(NextDispatcher next) async { + next(RequestingEventsAction(EventListType.comingSoon)); + + try { + final comingSoonEvents = await api.getUpcomingEvents(); + next(ReceivedComingSoonEventsAction(comingSoonEvents)); + } catch (e) { + print(e.toString()); + next(ErrorLoadingEventsAction(EventListType.comingSoon)); + } + } + + Theater _determineTheater(dynamic action, Store store) { + try { + return action is RefreshEventsAction + ? store.state.theaterState.currentTheater + : action.selectedTheater; + } catch (e) { + /// FIXME: Ugly hack because rush to release before Christmas rush. + return null; + } + } + + Future _refreshEvents( + Theater theater, RefreshEventsAction action, NextDispatcher next) { + if (action.type == EventListType.nowInTheaters) { + return _fetchNowPlayingEvents(theater, next); + } else { + return _fetchComingSoonEvents(next); + } + } +} diff --git a/core/lib/src/redux/event/event_reducer.dart b/core/lib/src/redux/event/event_reducer.dart new file mode 100644 index 00000000..309a15b6 --- /dev/null +++ b/core/lib/src/redux/event/event_reducer.dart @@ -0,0 +1,73 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/event/event_actions.dart'; +import 'package:core/src/redux/event/event_state.dart'; + +EventState eventReducer(EventState state, dynamic action) { + if (action is RequestingEventsAction) { + return _requestingEvents(state, action.type); + } else if (action is ReceivedInTheatersEventsAction) { + return state.copyWith( + nowInTheatersStatus: LoadingStatus.success, + nowInTheatersEvents: action.events, + ); + } else if (action is ReceivedComingSoonEventsAction) { + return state.copyWith( + comingSoonStatus: LoadingStatus.success, + comingSoonEvents: action.events, + ); + } else if (action is ErrorLoadingEventsAction) { + return _errorLoadingEvents(state, action.type); + } else if (action is UpdateActorsForEventAction) { + return _updateActorsForEvent(state, action); + } + + return state; +} + +EventState _requestingEvents(EventState state, EventListType type) { + final status = LoadingStatus.loading; + + if (type == EventListType.nowInTheaters) { + return state.copyWith(nowInTheatersStatus: status); + } + + return state.copyWith(comingSoonStatus: status); +} + +EventState _errorLoadingEvents(EventState state, EventListType type) { + final status = LoadingStatus.error; + + if (type == EventListType.nowInTheaters) { + return state.copyWith(nowInTheatersStatus: status); + } + + return state.copyWith(comingSoonStatus: status); +} + +EventState _updateActorsForEvent( + EventState state, UpdateActorsForEventAction action) { + final event = action.event; + event.actors = action.actors; + + return state.copyWith( + nowInTheatersEvents: + _addActorImagesToEvent(state.nowInTheatersEvents, event), + comingSoonEvents: _addActorImagesToEvent(state.comingSoonEvents, event), + ); +} + +List _addActorImagesToEvent( + List originalEvents, Event replacement) { + final newEvents = []..addAll(originalEvents); + final positionToReplace = originalEvents.indexWhere((candidate) { + return candidate.id == replacement.id; + }); + + if (positionToReplace > -1) { + newEvents[positionToReplace] = replacement; + } + + return newEvents; +} diff --git a/core/lib/src/redux/event/event_selectors.dart b/core/lib/src/redux/event/event_selectors.dart new file mode 100644 index 00000000..653a2b2c --- /dev/null +++ b/core/lib/src/redux/event/event_selectors.dart @@ -0,0 +1,66 @@ +import 'dart:collection'; + +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:reselect/reselect.dart'; + +final nowInTheatersSelector = createSelector2( + (AppState state) => state.eventState.nowInTheatersEvents, + (AppState state) => state.searchQuery, + _eventsOrEventSearch, +); + +final comingSoonSelector = createSelector2( + (AppState state) => state.eventState.comingSoonEvents, + (AppState state) => state.searchQuery, + _eventsOrEventSearch, +); + +Event eventByIdSelector(AppState state, String id) { + final predicate = (event) => event.id == id; + + return nowInTheatersSelector(state).firstWhere( + predicate, + orElse: () { + return comingSoonSelector(state).firstWhere( + predicate, + orElse: () => null, + ); + }, + ); +} + +Event eventForShowSelector(AppState state, Show show) { + return state.eventState.nowInTheatersEvents + .where((event) => event.id == show.eventId) + .first; +} + +List _eventsOrEventSearch(List events, String searchQuery) { + return searchQuery == null + ? _uniqueEvents(events) + : _eventsWithSearchQuery(events, searchQuery); +} + +/// Since Finnkino XML API considers "The Grinch" and "The Grinch 2D" to be two +/// completely different events, we might get a lot of duplication. We have to +/// do this hack because it is quite boring to display four movie posters that +/// are exactly the same. +List _uniqueEvents(List original) { + final uniqueEventMap = LinkedHashMap(); + original.forEach((event) { + uniqueEventMap[event.originalTitle] = event; + }); + + return uniqueEventMap.values.toList(); +} + +List _eventsWithSearchQuery(List original, String searchQuery) { + final searchQueryPattern = RegExp(searchQuery, caseSensitive: false); + + return original.where((event) { + return event.title.contains(searchQueryPattern) || + event.originalTitle.contains(searchQueryPattern); + }).toList(); +} diff --git a/core/lib/src/redux/event/event_state.dart b/core/lib/src/redux/event/event_state.dart new file mode 100644 index 00000000..a4cb3cee --- /dev/null +++ b/core/lib/src/redux/event/event_state.dart @@ -0,0 +1,58 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:meta/meta.dart'; + +@immutable +class EventState { + EventState({ + @required this.nowInTheatersStatus, + @required this.nowInTheatersEvents, + @required this.comingSoonStatus, + @required this.comingSoonEvents, + }); + + final LoadingStatus nowInTheatersStatus; + final List nowInTheatersEvents; + final LoadingStatus comingSoonStatus; + final List comingSoonEvents; + + factory EventState.initial() { + return EventState( + nowInTheatersStatus: LoadingStatus.idle, + nowInTheatersEvents: [], + comingSoonStatus: LoadingStatus.idle, + comingSoonEvents: [], + ); + } + + EventState copyWith({ + LoadingStatus nowInTheatersStatus, + List nowInTheatersEvents, + LoadingStatus comingSoonStatus, + List comingSoonEvents, + }) { + return EventState( + nowInTheatersStatus: nowInTheatersStatus ?? this.nowInTheatersStatus, + comingSoonStatus: comingSoonStatus ?? this.comingSoonStatus, + nowInTheatersEvents: nowInTheatersEvents ?? this.nowInTheatersEvents, + comingSoonEvents: comingSoonEvents ?? this.comingSoonEvents, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EventState && + runtimeType == other.runtimeType && + nowInTheatersStatus == other.nowInTheatersStatus && + comingSoonStatus == other.comingSoonStatus && + nowInTheatersEvents == other.nowInTheatersEvents && + comingSoonEvents == other.comingSoonEvents; + + @override + int get hashCode => + nowInTheatersStatus.hashCode ^ + comingSoonStatus.hashCode ^ + nowInTheatersEvents.hashCode ^ + comingSoonEvents.hashCode; +} diff --git a/core/lib/src/redux/show/show_actions.dart b/core/lib/src/redux/show/show_actions.dart new file mode 100644 index 00000000..5028807e --- /dev/null +++ b/core/lib/src/redux/show/show_actions.dart @@ -0,0 +1,25 @@ +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/models/theater.dart'; + +class UpdateShowDatesAction {} +class ShowDatesUpdatedAction { + ShowDatesUpdatedAction(this.dates); + final List dates; +} + +class FetchShowsIfNotLoadedAction {} + +class RequestingShowsAction {} +class RefreshShowsAction {} +class ReceivedShowsAction { + ReceivedShowsAction(this.cacheKey, this.shows); + final DateTheaterPair cacheKey; + final List shows; +} + +class ErrorLoadingShowsAction {} +class ChangeCurrentDateAction { + ChangeCurrentDateAction(this.date); + final DateTime date; +} \ No newline at end of file diff --git a/core/lib/src/redux/show/show_middleware.dart b/core/lib/src/redux/show/show_middleware.dart new file mode 100644 index 00000000..76642b29 --- /dev/null +++ b/core/lib/src/redux/show/show_middleware.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/networking/finnkino_api.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/show/show_actions.dart'; +import 'package:core/src/utils/clock.dart'; +import 'package:redux/redux.dart'; + +class ShowMiddleware extends MiddlewareClass { + ShowMiddleware(this.api); + final FinnkinoApi api; + + @override + Future call( + Store store, dynamic action, NextDispatcher next) async { + next(action); + + if (action is InitCompleteAction || action is UpdateShowDatesAction) { + await _updateShowDates(action, next); + } + + if (action is ChangeCurrentTheaterAction || + action is RefreshShowsAction || + action is ChangeCurrentDateAction) { + await _updateCurrentShows(store, action, next); + } + + if (action is FetchShowsIfNotLoadedAction) { + if (store.state.showState.loadingStatus == LoadingStatus.idle) { + await _updateCurrentShows(store, action, next); + } + } + } + + void _updateShowDates(dynamic action, NextDispatcher next) { + final now = Clock.getCurrentTime(); + var dates = List.generate(7, (index) => now.add(Duration(days: index))); + + next(new ShowDatesUpdatedAction(dates)); + } + + Future _updateCurrentShows( + Store store, dynamic action, NextDispatcher next) async { + next(RequestingShowsAction()); + + try { + final theater = _getCorrectTheater(store, action); + final date = _getCorrectDate(store, action); + final cacheKey = DateTheaterPair(date, theater); + + var shows = store.state.showState.shows[cacheKey]; + + if (shows == null) { + shows = await _fetchShows(date, theater, next); + } + + next(ReceivedShowsAction(DateTheaterPair(date, theater), shows)); + } catch (e) { + next(ErrorLoadingShowsAction()); + } + } + + Future> _fetchShows( + DateTime currentDate, Theater newTheater, NextDispatcher next) async { + final shows = await api.getSchedule(newTheater, currentDate); + final now = Clock.getCurrentTime(); + + // Return only show times that haven't started yet. + return shows.where((show) => show.start.isAfter(now)).toList(); + } + + Theater _getCorrectTheater(Store store, dynamic action) { + return action is InitCompleteAction || action is ChangeCurrentTheaterAction + ? action.selectedTheater + : store.state.theaterState.currentTheater; + } + + DateTime _getCorrectDate(Store store, dynamic action) { + return action is ChangeCurrentDateAction + ? action.date + : store.state.showState.selectedDate; + } +} diff --git a/core/lib/src/redux/show/show_reducer.dart b/core/lib/src/redux/show/show_reducer.dart new file mode 100644 index 00000000..a1cc67f9 --- /dev/null +++ b/core/lib/src/redux/show/show_reducer.dart @@ -0,0 +1,33 @@ +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/show/show_actions.dart'; +import 'package:core/src/redux/show/show_state.dart'; + +ShowState showReducer(ShowState state, dynamic action) { + if (action is ChangeCurrentTheaterAction) { + return state.copyWith(selectedDate: state.dates.first); + } else if (action is ChangeCurrentDateAction) { + return state.copyWith(selectedDate: action.date); + } else if (action is RequestingShowsAction) { + return state.copyWith(loadingStatus: LoadingStatus.loading); + } else if (action is ReceivedShowsAction) { + final newShows = >{}..addAll(state.shows); + newShows[action.cacheKey] = action.shows; + + return state.copyWith( + loadingStatus: LoadingStatus.success, + shows: newShows, + ); + } else if (action is ErrorLoadingShowsAction) { + return state.copyWith(loadingStatus: LoadingStatus.error); + } else if (action is ShowDatesUpdatedAction) { + return state.copyWith( + availableDates: action.dates, + selectedDate: action.dates.first, + ); + } + + return state; +} \ No newline at end of file diff --git a/core/lib/src/redux/show/show_selectors.dart b/core/lib/src/redux/show/show_selectors.dart new file mode 100644 index 00000000..6c1fd3a7 --- /dev/null +++ b/core/lib/src/redux/show/show_selectors.dart @@ -0,0 +1,68 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:memoize/memoize.dart'; +import 'package:reselect/reselect.dart'; + +Show showByIdSelector(AppState state, String id) { + return showsSelector(state).firstWhere( + (show) => show.id == id, + orElse: () => _findFromAllShows(state, id), + ); +} + +/// Selects a list of shows based on the currently selected date and theater. +/// +/// If the current AppState contains a search query, returns only shows that match +/// that search query. Otherwise returns all matching shows for current theater +/// and date. +final showsSelector = createSelector3>, String, List>( + (state) => DateTheaterPair.fromState(state), + (state) => state.showState.shows, + (state) => state.searchQuery, + (key, shows, searchQuery) { + final matchingShows = shows[key] ?? []; + + return searchQuery == null + ? matchingShows + : _showsWithSearchQuery(matchingShows, searchQuery); + }, +); + +final showsForEventSelector = + memo2, Event, List>((shows, event) { + return shows + .where((show) => show.originalTitle == event.originalTitle) + .toList(); +}); + +List _showsWithSearchQuery(List shows, String searchQuery) { + final searchQueryPattern = new RegExp(searchQuery, caseSensitive: false); + + return shows.where((show) { + return show.title.contains(searchQueryPattern) || + show.originalTitle.contains(searchQueryPattern); + }).toList(); +} + +/// Goes through the list of showtimes for every single theater. +/// +/// Skips all memoization and searches for correct show time through all shows +/// instead of shows specific to current theater and date. Used as a fallback +/// when [showByIdSelector] fails. +Show _findFromAllShows(AppState state, String id) { + final allShows = state.showState.shows.values; + return allShows.firstWhere( + (shows) { + final match = shows.firstWhere( + (show) => show.id == id, + orElse: () => null, + ); + + return match != null; + }, + orElse: () => null, + )?.first; +} diff --git a/lib/redux/show/show_state.dart b/core/lib/src/redux/show/show_state.dart similarity index 51% rename from lib/redux/show/show_state.dart rename to core/lib/src/redux/show/show_state.dart index ba104971..dd02bac1 100644 --- a/lib/redux/show/show_state.dart +++ b/core/lib/src/redux/show/show_state.dart @@ -1,6 +1,6 @@ -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/utils/clock.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; import 'package:meta/meta.dart'; @immutable @@ -15,21 +15,14 @@ class ShowState { final LoadingStatus loadingStatus; final List dates; final DateTime selectedDate; - final List shows; + final Map> shows; factory ShowState.initial() { - // TODO: Refactor this to a possibly more appropriate place, but where? - var now = Clock.getCurrentTime(); - var dates = new List.generate( - 7, - (index) => now.add(new Duration(days: index)), - ); - - return new ShowState( - loadingStatus: LoadingStatus.loading, - dates: dates, - selectedDate: dates.first, - shows: [], + return ShowState( + loadingStatus: LoadingStatus.idle, + dates: [], + selectedDate: null, + shows: {}, ); } @@ -37,9 +30,9 @@ class ShowState { LoadingStatus loadingStatus, List availableDates, DateTime selectedDate, - List shows, + Map> shows, }) { - return new ShowState( + return ShowState( loadingStatus: loadingStatus ?? this.loadingStatus, dates: availableDates ?? this.dates, selectedDate: selectedDate ?? this.selectedDate, @@ -50,12 +43,12 @@ class ShowState { @override bool operator ==(Object other) => identical(this, other) || - other is ShowState && - runtimeType == other.runtimeType && - loadingStatus == other.loadingStatus && - dates == other.dates && - selectedDate == other.selectedDate && - shows == other.shows; + other is ShowState && + runtimeType == other.runtimeType && + loadingStatus == other.loadingStatus && + dates == other.dates && + selectedDate == other.selectedDate && + shows == other.shows; @override int get hashCode => diff --git a/core/lib/src/redux/store.dart b/core/lib/src/redux/store.dart new file mode 100644 index 00000000..7a8b68e7 --- /dev/null +++ b/core/lib/src/redux/store.dart @@ -0,0 +1,28 @@ +import 'package:core/src/networking/finnkino_api.dart'; +import 'package:core/src/networking/tmdb_api.dart'; +import 'package:core/src/redux/actor/actor_middleware.dart'; +import 'package:core/src/redux/app/app_reducer.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_middleware.dart'; +import 'package:core/src/redux/show/show_middleware.dart'; +import 'package:core/src/redux/theater/theater_middleware.dart'; +import 'package:http/http.dart'; +import 'package:key_value_store/key_value_store.dart'; +import 'package:redux/redux.dart'; + +Store createStore(Client client, KeyValueStore keyValueStore) { + final tmdbApi = TMDBApi(client); + final finnkinoApi = FinnkinoApi(client); + + return Store( + appReducer, + initialState: AppState.initial(), + distinct: true, + middleware: [ + ActorMiddleware(tmdbApi), + TheaterMiddleware(keyValueStore), + ShowMiddleware(finnkinoApi), + EventMiddleware(finnkinoApi), + ], + ); +} diff --git a/core/lib/src/redux/theater/theater_middleware.dart b/core/lib/src/redux/theater/theater_middleware.dart new file mode 100644 index 00000000..79cd83c4 --- /dev/null +++ b/core/lib/src/redux/theater/theater_middleware.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:core/src/models/theater.dart'; +import 'package:core/src/parsers/theater_parser.dart'; +import 'package:core/src/preloaded_data.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:key_value_store/key_value_store.dart'; +import 'package:redux/redux.dart'; + +class TheaterMiddleware extends MiddlewareClass { + static const String kDefaultTheaterId = 'default_theater_id'; + + TheaterMiddleware(this.keyValueStore); + final KeyValueStore keyValueStore; + + @override + Future call( + Store store, dynamic action, NextDispatcher next) async { + if (action is InitAction) { + await _init(action, next); + } else if (action is ChangeCurrentTheaterAction) { + await _changeCurrentTheater(action, next); + } else { + next(action); + } + } + + Future _init(InitAction action, NextDispatcher next) async { + var theaterXml = PreloadedData.theaters; + var theaters = TheaterParser.parse(theaterXml); + var currentTheater = _getDefaultTheater(theaters); + + next(InitCompleteAction(theaters, currentTheater)); + } + + Future _changeCurrentTheater( + ChangeCurrentTheaterAction action, NextDispatcher next) async { + keyValueStore.setString(kDefaultTheaterId, action.selectedTheater.id); + next(action); + } + + Theater _getDefaultTheater(List allTheaters) { + var persistedTheaterId = keyValueStore.getString(kDefaultTheaterId); + + if (persistedTheaterId != null) { + return allTheaters.singleWhere((theater) { + return theater.id == persistedTheaterId; + }); + } + + return allTheaters.singleWhere( + /// Default to Helsinki -> no reason to load movie information for the + /// whole country at first. + (theater) => theater.id == '1033', + orElse: () => allTheaters.first, + ); + } +} diff --git a/core/lib/src/redux/theater/theater_reducer.dart b/core/lib/src/redux/theater/theater_reducer.dart new file mode 100644 index 00000000..88e050e4 --- /dev/null +++ b/core/lib/src/redux/theater/theater_reducer.dart @@ -0,0 +1,13 @@ +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/theater/theater_state.dart'; + +TheaterState theaterReducer(TheaterState state, dynamic action) { + if (action is InitCompleteAction) { + return state.copyWith( + currentTheater: action.selectedTheater, theaters: action.theaters); + } else if (action is ChangeCurrentTheaterAction) { + return state.copyWith(currentTheater: action.selectedTheater); + } + + return state; +} \ No newline at end of file diff --git a/lib/redux/theater/theater_selectors.dart b/core/lib/src/redux/theater/theater_selectors.dart similarity index 62% rename from lib/redux/theater/theater_selectors.dart rename to core/lib/src/redux/theater/theater_selectors.dart index 97cd1929..9c96d6e6 100644 --- a/lib/redux/theater/theater_selectors.dart +++ b/core/lib/src/redux/theater/theater_selectors.dart @@ -1,5 +1,5 @@ -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/app/app_state.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/app/app_state.dart'; Theater currentTheaterSelector(AppState state) => state.theaterState.currentTheater; diff --git a/lib/redux/theater/theater_state.dart b/core/lib/src/redux/theater/theater_state.dart similarity index 59% rename from lib/redux/theater/theater_state.dart rename to core/lib/src/redux/theater/theater_state.dart index 7c48a434..2ce94ff3 100644 --- a/lib/redux/theater/theater_state.dart +++ b/core/lib/src/redux/theater/theater_state.dart @@ -1,4 +1,4 @@ -import 'package:inkino/data/models/theater.dart'; +import 'package:core/src/models/theater.dart'; import 'package:meta/meta.dart'; @immutable @@ -12,9 +12,9 @@ class TheaterState { final List theaters; factory TheaterState.initial() { - return new TheaterState( + return TheaterState( currentTheater: null, - theaters: [], + theaters: [], ); } @@ -22,7 +22,7 @@ class TheaterState { Theater currentTheater, List theaters, }) { - return new TheaterState( + return TheaterState( currentTheater: currentTheater ?? this.currentTheater, theaters: theaters ?? this.theaters, ); @@ -31,13 +31,11 @@ class TheaterState { @override bool operator ==(Object other) => identical(this, other) || - other is TheaterState && - runtimeType == other.runtimeType && - currentTheater == other.currentTheater && - theaters == other.theaters; + other is TheaterState && + runtimeType == other.runtimeType && + currentTheater == other.currentTheater && + theaters == other.theaters; @override - int get hashCode => - currentTheater.hashCode ^ - theaters.hashCode; -} \ No newline at end of file + int get hashCode => currentTheater.hashCode ^ theaters.hashCode; +} diff --git a/core/lib/src/tmdb_config.dart.sample b/core/lib/src/tmdb_config.dart.sample new file mode 100644 index 00000000..3772704c --- /dev/null +++ b/core/lib/src/tmdb_config.dart.sample @@ -0,0 +1,8 @@ +class TMDBConfig { + /// The TMDB API is mostly used for loading actor avatars. + /// + /// Having a real API key here is optional; if this doesn't + /// contain the real API key, the app will still work, but + /// the actor avatars won't load. + static final String apiKey = ''; +} diff --git a/lib/utils/clock.dart b/core/lib/src/utils/clock.dart similarity index 93% rename from lib/utils/clock.dart rename to core/lib/src/utils/clock.dart index d537b819..828101ac 100644 --- a/lib/utils/clock.dart +++ b/core/lib/src/utils/clock.dart @@ -13,7 +13,7 @@ typedef DateTime DateTimeGetter(); /// list of all tests that use this class. class Clock { /// The default date time getter, which returns the current date and time. - static final defaultDateTimeGetter = () => new DateTime.now(); + static final defaultDateTimeGetter = () => DateTime.now(); /// Resets the current mock implementation (if any) for [getCurrentTime] /// method back to an implementation that returns the current date and time. diff --git a/lib/utils/event_name_cleaner.dart b/core/lib/src/utils/event_name_cleaner.dart similarity index 61% rename from lib/utils/event_name_cleaner.dart rename to core/lib/src/utils/event_name_cleaner.dart index d3d76d73..e2f31e25 100644 --- a/lib/utils/event_name_cleaner.dart +++ b/core/lib/src/utils/event_name_cleaner.dart @@ -7,16 +7,18 @@ class EventNameCleaner { /// "Avengers: Infinity War (2D dub)" -> "Avengers: Infinity War" /// /// For more, see test/event_name_cleaner_test.dart. - static final RegExp _pattern = - new RegExp(r"(\s([23]D$|\(([23]D|dub|orig|spanish|swe).*))"); + static final _pattern = RegExp( + r"(\s([23]D$|\(([23]D|dub|orig|spanish|swe|sing-along).*|\s*-\s*erikoisnäytös|\s*-\s*preview))", + caseSensitive: false, + ); static String cleanup(String name) { - var matches = _pattern.allMatches(name); - var hasNoise = matches.isNotEmpty; + final matches = _pattern.allMatches(name); + final hasNoise = matches.isNotEmpty; if (hasNoise) { // "noise" means (2D dub), (3D dub), etc. - var noise = matches.first.group(1); + final noise = matches.first.group(1); return name.replaceFirst(noise, ''); } diff --git a/lib/data/networking/http_utils.dart b/core/lib/src/utils/http_utils.dart similarity index 56% rename from lib/data/networking/http_utils.dart rename to core/lib/src/utils/http_utils.dart index dd6b9816..16893204 100644 --- a/lib/data/networking/http_utils.dart +++ b/core/lib/src/utils/http_utils.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -final _httpClient = new HttpClient(); +final _httpClient = HttpClient(); Future getRequest(Uri uri) async { - var request = await _httpClient.getUrl(uri); - var response = await request.close(); + final request = await _httpClient.getUrl(uri); + final response = await request.close(); return response.transform(utf8.decoder).join(); } diff --git a/lib/utils/xml_utils.dart b/core/lib/src/utils/xml_utils.dart similarity index 63% rename from lib/utils/xml_utils.dart rename to core/lib/src/utils/xml_utils.dart index 09833f1a..b2b78e7c 100644 --- a/lib/utils/xml_utils.dart +++ b/core/lib/src/utils/xml_utils.dart @@ -1,17 +1,17 @@ import 'package:xml/xml.dart' as xml; String tagContents(xml.XmlElement node, String tagName) { - var contents = tagContentsOrNull(node, tagName); + final contents = tagContentsOrNull(node, tagName); if (contents == null) { - throw new ArgumentError('Contents for $tagName were unexpectedly null.'); + throw ArgumentError('Contents for $tagName were unexpectedly null.'); } return contents; } String tagContentsOrNull(xml.XmlElement node, String tagName) { - var matches = node.findElements(tagName); + final matches = node.findElements(tagName); if (matches.isNotEmpty) { return matches.single.text; diff --git a/core/lib/src/viewmodels/events_page_view_model.dart b/core/lib/src/viewmodels/events_page_view_model.dart new file mode 100644 index 00000000..ccb04bbf --- /dev/null +++ b/core/lib/src/viewmodels/events_page_view_model.dart @@ -0,0 +1,46 @@ +import 'package:collection/collection.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_actions.dart'; +import 'package:core/src/redux/event/event_selectors.dart'; +import 'package:meta/meta.dart'; +import 'package:redux/redux.dart'; + +class EventsPageViewModel { + EventsPageViewModel({ + @required this.status, + @required this.events, + @required this.refreshEvents, + }); + + final LoadingStatus status; + final List events; + final Function refreshEvents; + + static EventsPageViewModel fromStore( + Store store, + EventListType type, + ) { + return EventsPageViewModel( + status: type == EventListType.nowInTheaters + ? store.state.eventState.nowInTheatersStatus + : store.state.eventState.comingSoonStatus, + events: type == EventListType.nowInTheaters + ? nowInTheatersSelector(store.state) + : comingSoonSelector(store.state), + refreshEvents: () => store.dispatch(RefreshEventsAction(type)), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EventsPageViewModel && + runtimeType == other.runtimeType && + status == other.status && + const IterableEquality().equals(events, other.events); + + @override + int get hashCode => status.hashCode ^ const IterableEquality().hash(events); +} diff --git a/lib/ui/showtimes/showtime_page_view_model.dart b/core/lib/src/viewmodels/showtime_page_view_model.dart similarity index 52% rename from lib/ui/showtimes/showtime_page_view_model.dart rename to core/lib/src/viewmodels/showtime_page_view_model.dart index 3147adbf..a8a431f6 100644 --- a/lib/ui/showtimes/showtime_page_view_model.dart +++ b/core/lib/src/viewmodels/showtime_page_view_model.dart @@ -1,8 +1,9 @@ -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/show/show_actions.dart'; -import 'package:inkino/redux/show/show_selectors.dart'; +import 'package:collection/collection.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/show/show_actions.dart'; +import 'package:core/src/redux/show/show_selectors.dart'; import 'package:meta/meta.dart'; import 'package:redux/redux.dart'; @@ -24,36 +25,32 @@ class ShowtimesPageViewModel { final Function refreshShowtimes; static ShowtimesPageViewModel fromStore(Store store) { - return new ShowtimesPageViewModel( + return ShowtimesPageViewModel( selectedDate: store.state.showState.selectedDate, dates: store.state.showState.dates, status: store.state.showState.loadingStatus, shows: showsSelector(store.state), changeCurrentDate: (newDate) { - store.dispatch(new ChangeCurrentDateAction(newDate)); + store.dispatch(ChangeCurrentDateAction(newDate)); }, - refreshShowtimes: () => store.dispatch(new RefreshShowsAction()), + refreshShowtimes: () => store.dispatch(RefreshShowsAction()), ); } @override bool operator ==(Object other) => identical(this, other) || - other is ShowtimesPageViewModel && - runtimeType == other.runtimeType && - status == other.status && - dates == other.dates && - selectedDate == other.selectedDate && - shows == other.shows && - changeCurrentDate == other.changeCurrentDate && - refreshShowtimes == other.refreshShowtimes; + other is ShowtimesPageViewModel && + runtimeType == other.runtimeType && + status == other.status && + const IterableEquality().equals(dates, other.dates) && + selectedDate == other.selectedDate && + const IterableEquality().equals(shows, other.shows); @override int get hashCode => status.hashCode ^ - dates.hashCode ^ + const IterableEquality().hash(dates) ^ selectedDate.hashCode ^ - shows.hashCode ^ - changeCurrentDate.hashCode ^ - refreshShowtimes.hashCode; + const IterableEquality().hash(shows); } diff --git a/lib/ui/theater_list/theater_list_view_model.dart b/core/lib/src/viewmodels/theater_list_view_model.dart similarity index 51% rename from lib/ui/theater_list/theater_list_view_model.dart rename to core/lib/src/viewmodels/theater_list_view_model.dart index d7fc9e5a..f31f00e4 100644 --- a/lib/ui/theater_list/theater_list_view_model.dart +++ b/core/lib/src/viewmodels/theater_list_view_model.dart @@ -1,7 +1,8 @@ -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/theater/theater_selectors.dart'; +import 'package:collection/collection.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/theater/theater_selectors.dart'; import 'package:meta/meta.dart'; import 'package:redux/redux.dart'; @@ -17,11 +18,11 @@ class TheaterListViewModel { final Function(Theater) changeCurrentTheater; static TheaterListViewModel fromStore(Store store) { - return new TheaterListViewModel( + return TheaterListViewModel( currentTheater: currentTheaterSelector(store.state), theaters: theatersSelector(store.state), changeCurrentTheater: (theater) { - store.dispatch(new ChangeCurrentTheaterAction(theater)); + store.dispatch(ChangeCurrentTheaterAction(theater)); }, ); } @@ -29,15 +30,12 @@ class TheaterListViewModel { @override bool operator ==(Object other) => identical(this, other) || - other is TheaterListViewModel && - runtimeType == other.runtimeType && - currentTheater == other.currentTheater && - theaters == other.theaters && - changeCurrentTheater == other.changeCurrentTheater; + other is TheaterListViewModel && + runtimeType == other.runtimeType && + currentTheater == other.currentTheater && + const IterableEquality().equals(theaters, other.theaters); @override int get hashCode => - currentTheater.hashCode ^ - theaters.hashCode ^ - changeCurrentTheater.hashCode; + currentTheater.hashCode ^ const IterableEquality().hash(theaters); } diff --git a/core/pubspec.yaml b/core/pubspec.yaml new file mode 100644 index 00000000..d31623d7 --- /dev/null +++ b/core/pubspec.yaml @@ -0,0 +1,21 @@ +name: core +description: Common shared code for inKino mobile and web. +version: 0.0.1 +#homepage: https://www.example.com +#author: ironman + +environment: + sdk: '>=2.0.0-dev.58.0 <3.0.0' + +dependencies: + redux: ^3.0.0 + xml: ^3.0.1 + intl: any + http: ^0.12.0 + reselect: ^0.4.0 + key_value_store: ^1.0.0 + +dev_dependencies: + test: ^1.3.0 + mockito: ^3.0.0 + intl_translation: 0.17.0 diff --git a/core/test/mocks.dart b/core/test/mocks.dart new file mode 100644 index 00000000..6a894029 --- /dev/null +++ b/core/test/mocks.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:core/src/networking/finnkino_api.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:key_value_store/key_value_store.dart'; +import 'package:mockito/mockito.dart'; +import 'package:redux/redux.dart'; + +class MockFile extends Mock implements File {} + +class MockFinnkinoApi extends Mock implements FinnkinoApi {} + +class MockStore extends Mock implements Store {} +class MockKeyValueStore extends Mock implements KeyValueStore {} \ No newline at end of file diff --git a/core/test/networking/finnkino_api_test.dart b/core/test/networking/finnkino_api_test.dart new file mode 100644 index 00000000..1f292683 --- /dev/null +++ b/core/test/networking/finnkino_api_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:core/src/models/theater.dart'; +import 'package:core/src/networking/finnkino_api.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; +import 'package:utf/utf.dart'; + +import '../parsers/event_test_seeds.ignore.dart'; +import '../parsers/show_test_seeds.ignore.dart'; + +void main() { + group('FinnkinoApi', () { + final date = DateTime(2018); + final theater = Theater( + id: 'abc123', + name: 'Test theater', + ); + + List requestLog = []; + + setUp(() { + requestLog.clear(); + }); + + MockClient _clientWithResponse(String value) { + return MockClient((request) { + requestLog.add(request); + + return Future(() { + /// Have to do this "toBytes" dance because apparently, it's hard to + /// get the Response do the encoding with utf8 instead of Latin 1. + return Response.bytes(encodeUtf8(value), 200); + }); + }); + } + + test('now in theaters', () async { + final api = FinnkinoApi(_clientWithResponse(eventsXml)); + await api.getNowInTheatersEvents(theater); + + final expectedUrl = + 'https://www.finnkino.fi/en/xml/Events?area=abc123&listType=NowInTheatres&includeGallery=true'; + + expect(requestLog.single.url.toString(), expectedUrl); + }); + + test('schedule', () async { + final api = FinnkinoApi(_clientWithResponse(showsXml)); + await api.getSchedule(theater, date); + + final expectedUrl = + 'https://www.finnkino.fi/en/xml/Schedule?area=abc123&dt=01.01.2018&includeGallery=true'; + + expect(requestLog.single.url.toString(), expectedUrl); + }); + + test('coming soon', () async { + final api = FinnkinoApi(_clientWithResponse(eventsXml)); + await api.getUpcomingEvents(); + + final expectedUrl = + 'https://www.finnkino.fi/en/xml/Events?listType=ComingSoon&includeGallery=true'; + + expect(requestLog.single.url.toString(), expectedUrl); + }); + + test('finnish api requests', () async { + final api = FinnkinoApi(_clientWithResponse(eventsXml)); + FinnkinoApi.useFinnish = true; + + await api.getNowInTheatersEvents(theater); + await api.getSchedule(theater, date); + await api.getUpcomingEvents(); + + expect(requestLog.length, 3); + + expect( + requestLog[0].url.toString(), + 'https://www.finnkino.fi/xml/Events?area=abc123&listType=NowInTheatres&includeGallery=true', + ); + + expect( + requestLog[1].url.toString(), + 'https://www.finnkino.fi/xml/Schedule?area=abc123&dt=01.01.2018&includeGallery=true', + ); + + expect( + requestLog[2].url.toString(), + 'https://www.finnkino.fi/xml/Events?listType=ComingSoon&includeGallery=true', + ); + }); + }); +} diff --git a/core/test/networking/imgix_url_rewriter_test.dart b/core/test/networking/imgix_url_rewriter_test.dart new file mode 100644 index 00000000..d3069dec --- /dev/null +++ b/core/test/networking/imgix_url_rewriter_test.dart @@ -0,0 +1,32 @@ +import 'package:core/src/networking/image_url_rewriter.dart'; +import 'package:test/test.dart'; + +void main() { + group('ImgixUrlRewriter', () { + test('url rewriting tests', () { + expect( + rewriteImageUrl( + 'http://media.finnkino.fi/1012/Event_11960/gallery/THUMB_AntManAndTheWasp_1200d.jpg', + ), + 'https://inkino.imgix.net/1012/Event_11960/gallery/THUMB_AntManAndTheWasp_1200d.jpg?auto=format,compress', + ); + + expect( + rewriteImageUrl( + 'https://media.finnkino.fi/1012/Event_11960/gallery/THUMB_AntManAndTheWasp_1200d.jpg', + ), + 'https://inkino.imgix.net/1012/Event_11960/gallery/THUMB_AntManAndTheWasp_1200d.jpg?auto=format,compress', + ); + + expect(rewriteImageUrl(null), null); + expect( + rewriteImageUrl('Not yet rated'), + 'https://inkino.imgix.net/images/rating_large_Tulossa.png?auto=format,compress', + ); + expect( + rewriteImageUrl('https://media.finnkino.fi/images/rating_large_Not%20yet%20rated.png'), + 'https://inkino.imgix.net/images/rating_large_Tulossa.png?auto=format,compress', + ); + }); + }); +} diff --git a/core/test/parsers/event_parser_test.dart b/core/test/parsers/event_parser_test.dart new file mode 100644 index 00000000..cbb1bf88 --- /dev/null +++ b/core/test/parsers/event_parser_test.dart @@ -0,0 +1,84 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/parsers/event_parser.dart'; +import 'package:test/test.dart'; + +import 'event_test_seeds.ignore.dart'; + +void main() { + group('EventParser', () { + test('parsing tests', () { + List deserialized = EventParser.parse(eventsXml); + expect(deserialized.length, 3); + + final paris1517 = deserialized.first; + expect(paris1517.id, '302535'); + expect(paris1517.title, '15:17 Pariisiin'); + expect(paris1517.originalTitle, 'The 15:17 to Paris'); + expect(paris1517.releaseDate, DateTime(2018, 02, 16)); + expect(paris1517.ageRating, '12'); + expect(paris1517.ageRatingUrl, + 'https://inkino.imgix.net/images/rating_large_12.png?auto=format,compress'); + expect(paris1517.genres, 'Draama, Jännitys'); + expect(paris1517.directors.length, 1); + expect(paris1517.directors.first, 'Clint Eastwood'); + expect(paris1517.actors.length, 11); + expect(paris1517.actors.first.name, 'Anthony Sadler'); + expect(paris1517.lengthInMinutes, '94'); + expect(paris1517.shortSynopsis, 'Short synopsis goes here.'); + expect(paris1517.synopsis, 'Synopsis goes here.'); + expect(paris1517.youtubeTrailers.length, 1); + expect(paris1517.youtubeTrailers.first, + 'https://youtube.com/watch?v=oFa4C6OcuM4'); + + final images = paris1517.images; + expect( + images.portraitSmall, + 'https://inkino.imgix.net/1012/Event_11881/portrait_small/The1517toParis_1080.jpg?auto=format,compress', + ); + expect( + images.portraitMedium, + 'https://inkino.imgix.net/1012/Event_11881/portrait_medium/The1517toParis_1080.jpg?auto=format,compress', + ); + expect( + images.portraitLarge, + 'https://inkino.imgix.net/1012/Event_11881/portrait_large/The1517toParis_1080.jpg?auto=format,compress', + ); + expect( + images.landscapeSmall, + 'https://inkino.imgix.net/1012/Event_11881/landscape_small/The1517toParis_444.jpg?auto=format,compress', + ); + expect( + images.landscapeBig, + 'https://inkino.imgix.net/1012/Event_11881/landscape_large/BookClub_670_kke.jpg?auto=format,compress', + ); + expect( + images.landscapeHd, + 'https://inkino.imgix.net/1012/Event_11881/landscape_hd/BookClub_1920_kke_.jpg?auto=format,compress', + ); + expect( + images.landscapeHd2, + 'https://inkino.imgix.net/1012/Event_11881/landscape_hd/BookClub_1920_kke.jpg?auto=format,compress', + ); + + final contentDescriptors = paris1517.contentDescriptors; + expect(contentDescriptors.length, 2); + expect(contentDescriptors[0].name, 'Violence'); + expect(contentDescriptors[0].imageUrl, + 'https://inkino.imgix.net/images/content_Violence.png?auto=format,compress'); + + final gallery = paris1517.galleryImages; + expect(gallery.length, 8); + expect(gallery.first.thumbnailLocation, + 'https://inkino.imgix.net/1012/Event_12007/gallery/THUMB_Adrift_800a.jpg?auto=format,compress'); + expect(gallery.first.location, + 'https://inkino.imgix.net/1012/Event_12007/gallery/Adrift_800a.jpg?auto=format,compress'); + + expect( + deserialized[1].ageRatingUrl, + 'https://inkino.imgix.net/images/rating_large_Tulossa.png?auto=format,compress', + ); + + expect(deserialized[1].actors, isEmpty); + }); + }); +} diff --git a/test_assets/events.xml b/core/test/parsers/event_test_seeds.ignore.dart similarity index 79% rename from test_assets/events.xml rename to core/test/parsers/event_test_seeds.ignore.dart index 0212826a..e371139e 100644 --- a/test_assets/events.xml +++ b/core/test/parsers/event_test_seeds.ignore.dart @@ -1,4 +1,4 @@ - +const String eventsXml = ''' 302535 @@ -21,9 +21,9 @@ http://media.finnkino.fi/1012/Event_11881/portrait_small/The1517toParis_1080.jpg http://media.finnkino.fi/1012/Event_11881/portrait_medium/The1517toParis_1080.jpg - http://media.finnkino.fi/1012/Event_11881/portrait_small/The1517toParis_1080.jpg + http://media.finnkino.fi/1012/Event_11881/portrait_large/The1517toParis_1080.jpg http://media.finnkino.fi/1012/Event_11881/landscape_small/The1517toParis_444.jpg - http://media.finnkino.fi/1012/Event_11881/landscape_large/The1517toParis_670.jpg + https://media.finnkino.fi/1012/Event_11881/landscape_large/BookClub_670_kke.jpg @@ -34,6 +34,48 @@ YouTubeVideo + + + + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800a.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800a.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800b.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800b.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800c.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800c.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800d.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800d.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800e.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800e.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800f.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800f.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800g.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800g.jpg</ThumbnailLocation> + </GalleryImage> + <GalleryImage> + <Title /> + <Location>http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800h.jpg</Location> + <ThumbnailLocation>http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800h.jpg</ThumbnailLocation> + </GalleryImage> + </Gallery> <Cast> <Actor> <FirstName>Anthony</FirstName> @@ -104,9 +146,9 @@ <ProductionYear>2017</ProductionYear> <LengthInMinutes>107</LengthInMinutes> <dtLocalRelease>2017-12-25T00:00:00</dtLocalRelease> - <Rating>7</Rating> - <RatingLabel>7</RatingLabel> - <RatingImageUrl>https://media.finnkino.fi/images/rating_large_7.png</RatingImageUrl> + <Rating>Not yet rated</Rating> + <RatingLabel>Not yet rated</RatingLabel> + <RatingImageUrl>https://media.finnkino.fi/images/rating_large_Not yet rated.png</RatingImageUrl> <LocalDistributorName>SF Film Finland Oy</LocalDistributorName> <GlobalDistributorName>SF Film Finland Oy</GlobalDistributorName> <ProductionCompanies>-</ProductionCompanies> @@ -132,32 +174,7 @@ <MediaResourceFormat>YouTubeVideo</MediaResourceFormat> </EventVideo> </Videos> - <Cast> - <Actor> - <FirstName>Jens</FirstName> - <LastName>Hulten</LastName> - </Actor> - <Actor> - <FirstName>Kari-Pekka</FirstName> - <LastName>Toivonen</LastName> - </Actor> - <Actor> - <FirstName>Jon-Jon</FirstName> - <LastName>Geitel</LastName> - </Actor> - <Actor> - <FirstName>Akseli</FirstName> - <LastName>Kouki</LastName> - </Actor> - <Actor> - <FirstName>Kustaa</FirstName> - <LastName>Tuohimaa</LastName> - </Actor> - <Actor> - <FirstName>Joel</FirstName> - <LastName>Hirvonen</LastName> - </Actor> - </Cast> + <Cast/> <Directors> <!-- Testing empty Directors element --> <Test>test</Test> @@ -250,4 +267,4 @@ </ContentDescriptor> </ContentDescriptors> </Event> -</Events> \ No newline at end of file +</Events>'''; diff --git a/core/test/parsers/show_parser_test.dart b/core/test/parsers/show_parser_test.dart new file mode 100644 index 00000000..b2fec404 --- /dev/null +++ b/core/test/parsers/show_parser_test.dart @@ -0,0 +1,65 @@ +import 'package:core/src/models/show.dart'; +import 'package:core/src/parsers/show_parser.dart'; +import 'package:test/test.dart'; + +import 'show_test_seeds.ignore.dart'; + +void main() { + group('ShowParser', () { + test('parsing test', () { + List<Show> deserialized = ShowParser.parse(showsXml); + expect(deserialized.length, 3); + + final jumanji = deserialized.first; + expect(jumanji.id, '1155306'); + expect(jumanji.eventId, '302419'); + expect(jumanji.title, 'Jumanji: Welcome to the Jungle'); + expect(jumanji.originalTitle, + 'Jumanji: Welcome to the Jungle (This is the original title)'); + expect(jumanji.ageRating, '12'); + expect(jumanji.ageRatingUrl, + 'https://inkino.imgix.net/images/rating_large_12.png?auto=format,compress'); + expect(jumanji.url, 'http://www.finnkino.fi/websales/show/1155306/'); + expect(jumanji.presentationMethod, '2D'); + expect(jumanji.theaterAndAuditorium, 'Tennispalatsi, Helsinki, sali 6'); + expect(jumanji.start, new DateTime(2018, 02, 21, 10, 30)); + expect(jumanji.end, new DateTime(2018, 02, 21, 12, 39)); + + final images = jumanji.images; + expect( + images.portraitSmall, + 'https://inkino.imgix.net/1012/Event_11765/portrait_small/Jumanji_1080u.jpg?auto=format,compress', + ); + expect( + images.portraitMedium, + 'https://inkino.imgix.net/1012/Event_11765/portrait_medium/Jumanji_1080u.jpg?auto=format,compress', + ); + expect( + images.portraitLarge, + 'https://inkino.imgix.net/1012/Event_11765/portrait_small/Jumanji_1080u.jpg?auto=format,compress', + ); + expect( + images.landscapeSmall, + 'https://inkino.imgix.net/1012/Event_11765/landscape_small/Jumanji_444.jpg?auto=format,compress', + ); + expect( + images.landscapeBig, + 'https://inkino.imgix.net/1012/Event_11765/landscape_large/Jumanji_670.jpg?auto=format,compress', + ); + expect( + images.landscapeHd, + 'https://inkino.imgix.net/1012/Event_11765/landscape_hd/Jumanji_1920_.jpg?auto=format,compress', + ); + expect( + images.landscapeHd2, + 'https://inkino.imgix.net/1012/Event_11765/landscape_hd/Jumanji_1920.jpg?auto=format,compress', + ); + + final contentDescriptors = jumanji.contentDescriptors; + expect(contentDescriptors.length, 2); + expect(contentDescriptors[0].name, 'Violence'); + expect(contentDescriptors[0].imageUrl, + 'https://inkino.imgix.net/images/content_Violence.png?auto=format,compress'); + }); + }); +} diff --git a/test_assets/schedule.xml b/core/test/parsers/show_test_seeds.ignore.dart similarity index 98% rename from test_assets/schedule.xml rename to core/test/parsers/show_test_seeds.ignore.dart index 3b812de5..5b6c4e84 100644 --- a/test_assets/schedule.xml +++ b/core/test/parsers/show_test_seeds.ignore.dart @@ -1,4 +1,4 @@ -<?xml version="1.0"?> +const String showsXml = '''<?xml version="1.0"?> <Schedule xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <PubDate>2018-02-21T00:00:00+02:00</PubDate> <Shows> @@ -19,7 +19,7 @@ <ShowReservationEndTimeUTC>2018-02-21T07:00:00Z</ShowReservationEndTimeUTC> <EventID>302419</EventID> <Title>Jumanji: Welcome to the Jungle - Jumanji: Welcome to the Jungle (Original title) + Jumanji: Welcome to the Jungle (This is the original title) 2017 119 2017-12-22T00:00:00 @@ -159,4 +159,4 @@ - \ No newline at end of file +'''; diff --git a/test/data/models/theater_test.dart b/core/test/parsers/theater_parser_test.dart similarity index 63% rename from test/data/models/theater_test.dart rename to core/test/parsers/theater_parser_test.dart index 613f63f1..02a10ef5 100644 --- a/test/data/models/theater_test.dart +++ b/core/test/parsers/theater_parser_test.dart @@ -1,14 +1,13 @@ -import 'dart:io'; - -import 'package:inkino/data/models/theater.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/parsers/theater_parser.dart'; import 'package:test/test.dart'; +import 'theater_test_seeds.ignore.dart'; + void main() { - group('Theater model', () { + group('TheaterParser', () { test('parsing test', () { - var theaters = new File('test_assets/theaters.xml').readAsStringSync(); - - List deserialized = Theater.parseAll(theaters); + List deserialized = TheaterParser.parse(theatersXml); expect(deserialized.length, 3); expect(deserialized[0].id, '1029'); @@ -21,4 +20,4 @@ void main() { expect(deserialized[2].name, 'Gotham: Theater Two'); }); }); -} \ No newline at end of file +} diff --git a/test_assets/theaters.xml b/core/test/parsers/theater_test_seeds.ignore.dart similarity index 85% rename from test_assets/theaters.xml rename to core/test/parsers/theater_test_seeds.ignore.dart index 0828f574..facc190d 100644 --- a/test_assets/theaters.xml +++ b/core/test/parsers/theater_test_seeds.ignore.dart @@ -1,4 +1,4 @@ - +const String theatersXml = ''' 1029 @@ -12,4 +12,4 @@ 002 Gotham: THEATER TWO - \ No newline at end of file +'''; diff --git a/test/redux/app/app_middleware_test.dart b/core/test/redux/actor_middleware_test.dart similarity index 53% rename from test/redux/app/app_middleware_test.dart rename to core/test/redux/actor_middleware_test.dart index 08c74279..2ac45d81 100644 --- a/test/redux/app/app_middleware_test.dart +++ b/core/test/redux/actor_middleware_test.dart @@ -1,9 +1,11 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/networking/tmdb_api.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_middleware.dart'; -import 'package:inkino/redux/common_actions.dart'; +import 'dart:async'; + +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/networking/tmdb_api.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/actor/actor_actions.dart'; +import 'package:core/src/redux/actor/actor_middleware.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -11,34 +13,34 @@ class MockTMDBApi extends Mock implements TMDBApi {} void main() { group('AppMiddleware', () { - final Event event = new Event( + final Event event = Event( id: 'test', - actors: [ - new Actor(name: 'Seth Ladd'), - new Actor(name: 'Eric Seidel'), + actors: [ + Actor(name: 'Seth Ladd'), + Actor(name: 'Eric Seidel'), ], ); - final List actorsWithAvatars = [ - new Actor( + final actorsWithAvatars = [ + Actor( name: 'Seth Ladd', avatarUrl: 'https://seths-profile-picture', ), - new Actor( + Actor( name: 'Eric Seidel', avatarUrl: 'https://erics-profile-picture', ), ]; - final List actionLog = []; - final Function(dynamic) next = (action) => actionLog.add(action); + final actionLog = []; + final Function(dynamic) next = (dynamic action) => actionLog.add(action); MockTMDBApi mockTMDBApi; - AppMiddleware sut; + ActorMiddleware middleware; setUp(() { - mockTMDBApi = new MockTMDBApi(); - sut = new AppMiddleware(mockTMDBApi); + mockTMDBApi = MockTMDBApi(); + middleware = ActorMiddleware(mockTMDBApi); }); tearDown(() { @@ -47,11 +49,11 @@ void main() { test('FetchActorAvatarsAction - successful API request', () async { when(mockTMDBApi.findAvatarsForActors(any, any)) - .thenReturn(actorsWithAvatars); - await sut.call(null, new FetchActorAvatarsAction(event), next); + .thenAnswer((_) => Future.value(actorsWithAvatars)); + await middleware.call(null, FetchActorAvatarsAction(event), next); expect(actionLog.length, 4); - expect(actionLog[0], new isInstanceOf()); + expect(actionLog[0], const TypeMatcher()); ActorsUpdatedAction actorsUpdated = actionLog[1]; expect(actorsUpdated.actors, event.actors); @@ -66,9 +68,9 @@ void main() { test('FetchActorAvatarsAction - handles errors silently', () async { when(mockTMDBApi.findAvatarsForActors(any, any)) - .thenReturn(new Error()); + .thenAnswer((_) => Future.error(Error())); - await sut.call(null, new FetchActorAvatarsAction(event), next); + await middleware.call(null, FetchActorAvatarsAction(event), next); }); }); } diff --git a/test/redux/app/app_reducer_test.dart b/core/test/redux/actor_reducer_test.dart similarity index 53% rename from test/redux/app/app_reducer_test.dart rename to core/test/redux/actor_reducer_test.dart index 681c35cd..ae92dbc9 100644 --- a/test/redux/app/app_reducer_test.dart +++ b/core/test/redux/actor_reducer_test.dart @@ -1,7 +1,7 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_reducer.dart'; -import 'package:inkino/redux/app/app_state.dart'; +import 'package:core/src/models/actor.dart'; +import 'package:core/src/redux/actor/actor_actions.dart'; +import 'package:core/src/redux/app/app_reducer.dart'; +import 'package:core/src/redux/app/app_state.dart'; import 'package:test/test.dart'; void main() { @@ -9,40 +9,40 @@ void main() { test( 'when called with ActorsUpdatedAction, should not modify existing actors', () { - var state = new AppState.initial().copyWith( + final state = AppState.initial().copyWith( actorsByName: { - 'Seth Ladd': new Actor( + 'Seth Ladd': Actor( name: 'Seth Ladd', avatarUrl: 'https://seths-avatar-url', ), - 'Eric Seidel': new Actor( + 'Eric Seidel': Actor( name: 'Eric Seidel', avatarUrl: 'https://erics-avatar-url', ), }, ); - var reducedState = appReducer( + final reducedState = appReducer( state, - new ActorsUpdatedAction([ - new Actor(name: 'Seth Ladd', avatarUrl: null), - new Actor(name: 'Eric Seidel', avatarUrl: null), - new Actor(name: 'Ian Hickson', avatarUrl: null), + ActorsUpdatedAction([ + Actor(name: 'Seth Ladd', avatarUrl: null), + Actor(name: 'Eric Seidel', avatarUrl: null), + Actor(name: 'Ian Hickson', avatarUrl: null), ]), ); expect( reducedState.actorsByName, { - 'Seth Ladd': new Actor( + 'Seth Ladd': Actor( name: 'Seth Ladd', avatarUrl: 'https://seths-avatar-url', ), - 'Eric Seidel': new Actor( + 'Eric Seidel': Actor( name: 'Eric Seidel', avatarUrl: 'https://erics-avatar-url', ), - 'Ian Hickson': new Actor( + 'Ian Hickson': Actor( name: 'Ian Hickson', avatarUrl: null, ), @@ -53,29 +53,29 @@ void main() { test( 'when called with ReceivedActorAvatarsAction, should add urls for actors', () { - var state = new AppState.initial().copyWith( + final state = AppState.initial().copyWith( actorsByName: { - 'Seth Ladd': new Actor(name: 'Seth Ladd', avatarUrl: null), - 'Eric Seidel': new Actor(name: 'Eric Seidel', avatarUrl: null), + 'Seth Ladd': Actor(name: 'Seth Ladd', avatarUrl: null), + 'Eric Seidel': Actor(name: 'Eric Seidel', avatarUrl: null), }, ); - var reducedState = appReducer( + final reducedState = appReducer( state, - new ReceivedActorAvatarsAction([ - new Actor(name: 'Seth Ladd', avatarUrl: 'https://seths-avatar-url'), - new Actor(name: 'Eric Seidel', avatarUrl: 'https://erics-avatar-url'), + ReceivedActorAvatarsAction([ + Actor(name: 'Seth Ladd', avatarUrl: 'https://seths-avatar-url'), + Actor(name: 'Eric Seidel', avatarUrl: 'https://erics-avatar-url'), ]), ); expect( reducedState.actorsByName, { - 'Seth Ladd': new Actor( + 'Seth Ladd': Actor( name: 'Seth Ladd', avatarUrl: 'https://seths-avatar-url', ), - 'Eric Seidel': new Actor( + 'Eric Seidel': Actor( name: 'Eric Seidel', avatarUrl: 'https://erics-avatar-url', ), diff --git a/core/test/redux/event_middleware_test.dart b/core/test/redux/event_middleware_test.dart new file mode 100644 index 00000000..a113d03e --- /dev/null +++ b/core/test/redux/event_middleware_test.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_actions.dart'; +import 'package:core/src/redux/event/event_middleware.dart'; +import 'package:core/src/redux/event/event_state.dart'; +import 'package:core/src/redux/theater/theater_state.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + group('EventMiddleware', () { + final Theater theater = Theater(id: 'test', name: 'Test Theater'); + final actionLog = []; + final next = (dynamic action) => actionLog.add(action); + + MockFinnkinoApi mockFinnkinoApi; + EventMiddleware middleware; + MockStore mockStore; + + final nowInTheatersEvents = [ + Event(), + Event(), + Event(), + ]; + + final upcomingEvents = [ + Event(), + Event(), + Event(), + ]; + + setUp(() { + mockFinnkinoApi = MockFinnkinoApi(); + middleware = EventMiddleware(mockFinnkinoApi); + mockStore = MockStore(); + + when(mockStore.state).thenReturn( + AppState.initial().copyWith( + theaterState: TheaterState.initial().copyWith( + currentTheater: Theater(id: 'test', name: 'Test Theater'), + ), + ), + ); + }); + + tearDown(() { + actionLog.clear(); + }); + + test( + 'when called with InitCompleteAction, should dispatch a ReceivedEventsAction with now playing events', + () async { + when(mockFinnkinoApi.getNowInTheatersEvents(any)) + .thenAnswer((_) => Future.value(nowInTheatersEvents)); + when(mockFinnkinoApi.getUpcomingEvents()) + .thenAnswer((_) => Future.value(upcomingEvents)); + + await middleware.call( + mockStore, InitCompleteAction(null, theater), next); + + expect(actionLog.length, 3); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + + final ReceivedInTheatersEventsAction action = actionLog[2]; + expect(action.events, nowInTheatersEvents); + }, + ); + + test('fetch only now in theaters events', () async { + when(mockFinnkinoApi.getNowInTheatersEvents(any)) + .thenAnswer((_) => Future.value(nowInTheatersEvents)); + + await middleware.call( + mockStore, RefreshEventsAction(EventListType.nowInTheaters), next); + + expect(actionLog.length, 3); + + final RefreshEventsAction refreshAction = actionLog[0]; + final RequestingEventsAction requestingAction = actionLog[1]; + final ReceivedInTheatersEventsAction action = actionLog[2]; + + expect(refreshAction.type, EventListType.nowInTheaters); + expect(requestingAction.type, EventListType.nowInTheaters); + expect(action.events, nowInTheatersEvents); + }); + + test('fetch only upcoming events', () async { + when(mockFinnkinoApi.getUpcomingEvents()) + .thenAnswer((_) => Future.value(upcomingEvents)); + + await middleware.call( + mockStore, RefreshEventsAction(EventListType.comingSoon), next); + + expect(actionLog.length, 3); + + final RefreshEventsAction refreshAction = actionLog[0]; + final RequestingEventsAction requestingAction = actionLog[1]; + final ReceivedComingSoonEventsAction action = actionLog[2]; + + expect(refreshAction.type, EventListType.comingSoon); + expect(requestingAction.type, EventListType.comingSoon); + expect(action.events, upcomingEvents); + }); + + test('fetch upcoming events if not loaded', () async { + when(mockFinnkinoApi.getUpcomingEvents()) + .thenAnswer((_) => Future.value(upcomingEvents)); + when(mockStore.state).thenReturn( + AppState.initial().copyWith( + eventState: EventState.initial().copyWith( + comingSoonStatus: LoadingStatus.idle, + ), + ), + ); + + await middleware.call( + mockStore, FetchComingSoonEventsIfNotLoadedAction(), next); + + expect(actionLog.length, 3); + expect(actionLog[0], + const TypeMatcher()); + + final RequestingEventsAction requestingAction = actionLog[1]; + final ReceivedComingSoonEventsAction action = actionLog[2]; + + expect(requestingAction.type, EventListType.comingSoon); + expect(action.events, upcomingEvents); + }); + + test( + 'when called with ChangeCurrentTheaterAction, should request events for the theater', + () async { + when(mockFinnkinoApi.getNowInTheatersEvents(any)) + .thenAnswer((_) => Future.value(nowInTheatersEvents)); + when(mockFinnkinoApi.getUpcomingEvents()) + .thenAnswer((_) => Future.value(upcomingEvents)); + + await middleware.call( + null, + ChangeCurrentTheaterAction( + Theater( + id: 'changed', + name: 'A newly selected theater', + ), + ), + next, + ); + + Theater captured = + verify(mockFinnkinoApi.getNowInTheatersEvents(captureAny)) + .captured + .first; + expect(captured.id, 'changed'); + expect(captured.name, 'A newly selected theater'); + + verify(mockFinnkinoApi.getUpcomingEvents()); + }, + ); + + test( + 'when InitCompleteAction results in an error, should dispatch an ErrorLoadingEventsAction', + () async { + when(mockFinnkinoApi.getNowInTheatersEvents(any)) + .thenAnswer((_) => Future.error(Error())); + when(mockFinnkinoApi.getUpcomingEvents()) + .thenAnswer((_) => Future.error(Error())); + + await middleware.call( + mockStore, InitCompleteAction(null, theater), next); + + expect(actionLog.length, 3); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + expect(actionLog[2], const TypeMatcher()); + }, + ); + }); +} diff --git a/core/test/redux/event_reducer_test.dart b/core/test/redux/event_reducer_test.dart new file mode 100644 index 00000000..41f1883a --- /dev/null +++ b/core/test/redux/event_reducer_test.dart @@ -0,0 +1,106 @@ +import 'package:core/src/models/actor.dart'; +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/event/event_actions.dart'; +import 'package:core/src/redux/event/event_reducer.dart'; +import 'package:core/src/redux/event/event_state.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventReducer', () { + test('when receiving now in theaters events', () { + final events = [ + Event(id: 'now in'), + Event(id: 'theaters'), + ]; + + final state = EventState.initial(); + final reducedState = + eventReducer(state, ReceivedInTheatersEventsAction(events)); + + expect(reducedState.nowInTheatersStatus, LoadingStatus.success); + expect(reducedState.nowInTheatersEvents, events); + }); + + test('when receiving upcoming events', () { + final events = [ + Event(id: 'coming'), + Event(id: 'soon'), + ]; + + final state = EventState.initial(); + final reducedState = + eventReducer(state, ReceivedComingSoonEventsAction(events)); + + expect(reducedState.comingSoonStatus, LoadingStatus.success); + expect(reducedState.comingSoonEvents, events); + }); + + test( + 'when called with UpdateActorsForEventAction, should update event actors when it is a now playing event', + () { + final state = EventState.initial().copyWith( + nowInTheatersEvents: [ + Event(id: '1'), + Event(id: 'event-to-update'), + Event(id: '2'), + ], + ); + + final reducedState = eventReducer( + state, + UpdateActorsForEventAction( + Event(id: 'event-to-update'), + [ + Actor(name: 'Eric Seidel', avatarUrl: 'http://erics-avatar'), + Actor(name: 'Seth Ladd', avatarUrl: 'http://seths-avatar'), + ], + ), + ); + + final nowInTheatersEvents = reducedState.nowInTheatersEvents; + expect(nowInTheatersEvents[0].actors, isNull); + expect(nowInTheatersEvents[2].actors, isNull); + + final updatedEvent = nowInTheatersEvents[1]; + expect(updatedEvent.actors.length, 2); + expect(updatedEvent.actors[0].name, 'Eric Seidel'); + expect(updatedEvent.actors[1].name, 'Seth Ladd'); + }, + ); + + test( + 'when called with UpdateActorsForEventAction, should update event actors when it is a upcoming event', + () { + final state = EventState.initial().copyWith( + comingSoonEvents: [ + Event(id: '1'), + Event(id: 'event-to-update'), + Event(id: '2'), + ], + ); + + final reducedState = eventReducer( + state, + UpdateActorsForEventAction( + Event(id: 'event-to-update'), + [ + Actor(name: 'Eric Seidel', avatarUrl: 'http://erics-avatar'), + Actor(name: 'Seth Ladd', avatarUrl: 'http://seths-avatar'), + ], + ), + ); + + final comingSoonEvents = reducedState.comingSoonEvents; + expect(comingSoonEvents[0].actors, isNull); + expect(comingSoonEvents[2].actors, isNull); + + final updatedEvent = comingSoonEvents[1]; + expect(updatedEvent.actors.length, 2); + expect(updatedEvent.actors[0].name, 'Eric Seidel'); + expect(updatedEvent.actors[1].name, 'Seth Ladd'); + }, + ); + }); +} diff --git a/core/test/redux/event_selector_test.dart b/core/test/redux/event_selector_test.dart new file mode 100644 index 00000000..f4aa63a9 --- /dev/null +++ b/core/test/redux/event_selector_test.dart @@ -0,0 +1,77 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/event/event_selectors.dart'; +import 'package:core/src/redux/event/event_state.dart'; +import 'package:test/test.dart'; + +void main() { + group('Event selectors', () { + final firstInTheaterEvent = Event( + id: 'in-theater-1', + originalTitle: 'In theater event #1', + title: 'In theater event #1', + ); + final secondInTheaterEvent = Event( + id: 'in-theater-2', + originalTitle: 'In theater event #2', + title: 'In theater event #2', + ); + final nowInTheatersEvents = [firstInTheaterEvent, secondInTheaterEvent]; + + final firstComingSoonEvent = Event( + id: 'coming-soon-1', + originalTitle: 'Coming soon event #1', + title: 'Coming soon event #1', + ); + final secondComingSoonEvent = Event( + id: 'coming-soon-2', + originalTitle: 'Coming soon event #2', + title: 'Coming soon event #2', + ); + final comingSoonEvents = [firstComingSoonEvent, secondComingSoonEvent]; + + final state = AppState.initial().copyWith( + eventState: EventState.initial().copyWith( + nowInTheatersEvents: nowInTheatersEvents, + comingSoonEvents: comingSoonEvents, + ), + ); + + test('events', () { + expect(nowInTheatersSelector(state), nowInTheatersEvents); + expect(comingSoonSelector(state), comingSoonEvents); + }); + + test('events with search query', () { + final stateWithSearchQuery = state.copyWith(searchQuery: '2'); + + expect( + nowInTheatersSelector(stateWithSearchQuery), + [secondInTheaterEvent], + ); + + expect( + comingSoonSelector(stateWithSearchQuery), + [secondComingSoonEvent], + ); + }); + + test('event by id', () { + expect(eventByIdSelector(state, 'in-theater-1'), firstInTheaterEvent); + expect(eventByIdSelector(state, 'in-theater-2'), secondInTheaterEvent); + expect(eventByIdSelector(state, 'coming-soon-1'), firstComingSoonEvent); + expect(eventByIdSelector(state, 'coming-soon-2'), secondComingSoonEvent); + }); + + test('event for show', () { + final show = Show(eventId: 'in-theater-2'); + expect(eventForShowSelector(state, show), secondInTheaterEvent); + }); + + test('event by id - null', () { + // If no crash, this is considered a passing test. + expect(eventByIdSelector(state, null), isNull); + }); + }); +} diff --git a/core/test/redux/search_test.dart b/core/test/redux/search_test.dart new file mode 100644 index 00000000..7fe9cc65 --- /dev/null +++ b/core/test/redux/search_test.dart @@ -0,0 +1,14 @@ +import 'package:core/src/redux/_common/search.dart'; +import 'package:test/test.dart'; + +void main() { + group('searchQueryReducer', () { + test('search reducer tests', () { + final state = null; + final reducedState = + searchQueryReducer(state, SearchQueryChangedAction('test')); + + expect(reducedState, 'test'); + }); + }); +} diff --git a/core/test/redux/show_middleware_test.dart b/core/test/redux/show_middleware_test.dart new file mode 100644 index 00000000..a654dead --- /dev/null +++ b/core/test/redux/show_middleware_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/show/show_actions.dart'; +import 'package:core/src/redux/show/show_middleware.dart'; +import 'package:core/src/redux/show/show_state.dart'; +import 'package:core/src/redux/theater/theater_state.dart'; +import 'package:core/src/utils/clock.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + group('ShowMiddleware', () { + final DateTime startOf2018 = DateTime(2018); + final Theater theater = Theater(id: 'abc123', name: 'Test Theater'); + final actionLog = []; + final showCache = >{}; + final Function(dynamic) next = (dynamic action) { + if (action is ReceivedShowsAction) { + showCache[action.cacheKey] = action.shows; + } + + actionLog.add(action); + }; + + MockFinnkinoApi mockFinnkinoApi; + MockStore mockStore; + ShowMiddleware middleware; + + AppState _stateBoilerplate({Theater currentTheater}) { + return AppState.initial().copyWith( + theaterState: TheaterState.initial().copyWith( + currentTheater: currentTheater, + ), + showState: ShowState.initial().copyWith( + selectedDate: startOf2018, + shows: showCache, + ), + ); + } + + Future changeDate(DateTime newDate) { + return middleware.call(mockStore, ChangeCurrentDateAction(newDate), next); + } + + Future changeTheater(Theater newTheater) { + return middleware.call( + mockStore, ChangeCurrentTheaterAction(newTheater), next); + } + + setUp(() { + mockFinnkinoApi = MockFinnkinoApi(); + mockStore = MockStore(); + middleware = ShowMiddleware(mockFinnkinoApi); + + when(mockStore.state) + .thenReturn(_stateBoilerplate(currentTheater: theater)); + }); + + tearDown(() { + actionLog.clear(); + showCache.clear(); + Clock.resetDateTimeGetter(); + }); + + test( + 'when called with InitCompleteAction, should update show dates', + () async { + // The middleware filters shows based on if the showtime has already + // passed. As DateTime(2018) will mean the very first hour and minute + // in January, all the show times in test assets will be after this date. + Clock.getCurrentTime = () => startOf2018; + + await middleware.call( + mockStore, InitCompleteAction(null, theater), next); + + expect(actionLog.length, 2); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + }, + ); + + test( + 'when called with FetchShowsIfNotLoadedAction, should fetch shows', + () async { + Clock.getCurrentTime = () => startOf2018; + when(mockFinnkinoApi.getSchedule(theater, any)) + .thenAnswer((_) => Future.value([ + Show(start: DateTime(2018, 02, 21)), + Show(start: DateTime(2018, 02, 21)), + Show(start: DateTime(2018, 03, 21)), + ])); + + await middleware.call(mockStore, FetchShowsIfNotLoadedAction(), next); + + verify(mockFinnkinoApi.getSchedule(theater, startOf2018)); + + expect(actionLog.length, 3); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + + final ReceivedShowsAction receivedShowsAction = actionLog[2]; + expect(receivedShowsAction.shows.length, 3); + + final showCacheKey = showCache.keys.first; + expect(showCacheKey.theater, theater); + expect(showCacheKey.dateTime, startOf2018); + }, + ); + + test( + 'when called with ChangeCurrentDateAction, should dispatch a ReceivedShowsAction with only relevant shows', + () async { + // Given + Clock.getCurrentTime = () => DateTime(2018, 3); + when(mockFinnkinoApi.getSchedule(theater, any)) + .thenAnswer((_) => Future.value( + [ + Show(start: DateTime(2018, 02, 21)), + Show(start: DateTime(2018, 02, 21)), + Show(start: DateTime(2018, 03, 21)), + ], + )); + + // When + await changeDate(startOf2018); + + // Then + verify(mockFinnkinoApi.getSchedule(theater, startOf2018)); + + expect(actionLog.length, 3); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + + final ReceivedShowsAction receivedShowsAction = actionLog[2]; + expect(receivedShowsAction.shows.length, 1); + }, + ); + + test( + 'when FetchShowsIfNotLoadedAction results in an error, should dispatch an ErrorLoadingShowsAction', + () async { + // Given + when(mockFinnkinoApi.getSchedule(any, any)) + .thenAnswer((_) => Future.error(Error())); + + // When + await middleware.call(mockStore, FetchShowsIfNotLoadedAction(), next); + + // Then + expect(actionLog.length, 3); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + expect(actionLog[2], const TypeMatcher()); + }, + ); + + test( + 'when called with UpdateShowDatesAction', + () async { + Clock.getCurrentTime = () => DateTime(2018, 1, 1); + + await middleware.call(mockStore, UpdateShowDatesAction(), next); + + expect(actionLog.length, 2); + expect(actionLog[0], const TypeMatcher()); + expect(actionLog[1], const TypeMatcher()); + + ShowDatesUpdatedAction action = actionLog[1]; + expect( + action.dates, + [ + DateTime(2018, 1, 1), + DateTime(2018, 1, 2), + DateTime(2018, 1, 3), + DateTime(2018, 1, 4), + DateTime(2018, 1, 5), + DateTime(2018, 1, 6), + DateTime(2018, 1, 7), + ], + ); + }, + ); + + test( + 'should only fetch shows for specific date and theater once', + () async { + Clock.getCurrentTime = () => DateTime(2017, 1, 1); + + when(mockStore.state).thenReturn(_stateBoilerplate()); + when(mockFinnkinoApi.getSchedule(any, any)) + .thenAnswer((_) => Future.value([])); + + final firstDate = DateTime(2018); + final secondDate = DateTime(2019); + + await changeDate(firstDate); + await changeDate(secondDate); + await changeDate(firstDate); + await changeDate(secondDate); + + verify(mockFinnkinoApi.getSchedule(any, firstDate)).called(1); + verify(mockFinnkinoApi.getSchedule(any, secondDate)).called(1); + + final firstTheater = Theater(id: 'first', name: 'First theater'); + final secondTheater = Theater(id: 'second', name: 'Second theater'); + + await changeTheater(firstTheater); + await changeTheater(secondTheater); + await changeTheater(firstTheater); + await changeTheater(secondTheater); + + verify(mockFinnkinoApi.getSchedule(firstTheater, any)).called(1); + verify(mockFinnkinoApi.getSchedule(secondTheater, any)).called(1); + + verifyNoMoreInteractions(mockFinnkinoApi); + }, + ); + }); +} diff --git a/core/test/redux/show_reducer_test.dart b/core/test/redux/show_reducer_test.dart new file mode 100644 index 00000000..04a0a90a --- /dev/null +++ b/core/test/redux/show_reducer_test.dart @@ -0,0 +1,35 @@ +import 'package:core/src/redux/show/show_actions.dart'; +import 'package:core/src/redux/show/show_reducer.dart'; +import 'package:core/src/redux/show/show_state.dart'; +import 'package:test/test.dart'; + +void main() { + group('ShowReducer', () { + test( + 'when called with ShowDatesUpdatedAction, should update state with 7 days from today', + () { + final initialState = ShowState.initial(); + final reducedState = showReducer( + initialState, + ShowDatesUpdatedAction( + [ + DateTime(2018, 1, 1), + DateTime(2018, 1, 2), + ], + ), + ); + + expect( + reducedState.dates, + [ + DateTime(2018, 1, 1), + DateTime(2018, 1, 2), + ], + ); + + // Should also select the first date in the list + expect(reducedState.selectedDate, DateTime(2018, 1, 1)); + }, + ); + }); +} diff --git a/core/test/redux/show_selector_test.dart b/core/test/redux/show_selector_test.dart new file mode 100644 index 00000000..7800b2ce --- /dev/null +++ b/core/test/redux/show_selector_test.dart @@ -0,0 +1,80 @@ +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/models/show_cache.dart'; +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/app/app_state.dart'; +import 'package:core/src/redux/show/show_selectors.dart'; +import 'package:core/src/redux/show/show_state.dart'; +import 'package:core/src/redux/theater/theater_state.dart'; +import 'package:test/test.dart'; + +void main() { + group('Show selectors', () { + final firstShow = + Show(id: 'first', title: 'First show', originalTitle: 'First show'); + final secondShow = + Show(id: 'second', title: 'Second show', originalTitle: 'Second show'); + final showWithAnotherCacheKey = Show(id: 'show-with-another-cache-key'); + final shows = [firstShow, secondShow]; + + final theater = Theater(id: 'test', name: 'Test Theater'); + final date = DateTime(2018); + final state = AppState.initial().copyWith( + theaterState: TheaterState.initial().copyWith( + currentTheater: theater, + ), + showState: ShowState.initial().copyWith( + loadingStatus: LoadingStatus.success, + selectedDate: date, + shows: { + DateTheaterPair(date, theater): shows, + DateTheaterPair(null, null): [showWithAnotherCacheKey], + }, + ), + ); + + test('show by id', () { + expect(showByIdSelector(state, 'first'), firstShow); + expect(showByIdSelector(state, 'second'), secondShow); + expect(showByIdSelector(state, 'null show'), isNull); + }); + + test('show by id - falls back to searching through all shows', () { + /// When no shows found for current theater and date, should find a show + /// from all available shows in the cache. + expect( + showByIdSelector(state, 'show-with-another-cache-key'), + showWithAnotherCacheKey, + ); + }); + + test('shows without search query', () { + expect(showsSelector(state), shows); + + /// When no shows found, should return empty show list instead of null. + expect(showsSelector(AppState.initial()), isEmpty); + }); + + test('shows with search query', () { + final stateWithSearchQuery = state.copyWith(searchQuery: 'Sec'); + expect(showsSelector(stateWithSearchQuery), [secondShow]); + + /// When no shows found, should return empty show list instead of null. + expect( + showsSelector( + stateWithSearchQuery.copyWith( + showState: ShowState.initial().copyWith( + shows: {}, + ), + ), + ), + isEmpty, + ); + }); + + test('showById - null', () { + // If no crash, this is considered a passing test. + expect(showByIdSelector(state, null), isNull); + }); + }); +} diff --git a/core/test/redux/theater_middleware_test.dart b/core/test/redux/theater_middleware_test.dart new file mode 100644 index 00000000..705025c1 --- /dev/null +++ b/core/test/redux/theater_middleware_test.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:core/src/models/theater.dart'; +import 'package:core/src/redux/_common/common_actions.dart'; +import 'package:core/src/redux/theater/theater_middleware.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + group('TheaterMiddleware', () { + final log = []; + final next = (dynamic action) => log.add(action); + + MockKeyValueStore mockKeyValueStore; + TheaterMiddleware middleware; + + setUp(() { + mockKeyValueStore = MockKeyValueStore(); + middleware = TheaterMiddleware(mockKeyValueStore); + }); + + tearDown(() { + log.clear(); + }); + + group('called with InitAction', () { + test('loads the preloaded theaters', () async { + // When + await middleware.call(null, InitAction(), next); + + // Then + final InitCompleteAction action = log.single; + expect(action.theaters.length, 20); + }); + + test('when a persisted theater id exists, uses that as a default', + () async { + when(mockKeyValueStore.getString(TheaterMiddleware.kDefaultTheaterId)) + .thenReturn('1012'); + + await middleware.call(null, InitAction(), next); + + final InitCompleteAction action = log.single; + Theater theater = action.selectedTheater; + expect(theater.id, '1012'); + expect(theater.name, 'Espoo'); + }); + + test( + 'when no persisted theater id, defaults to Helsinki', + () async { + when(mockKeyValueStore.getString(TheaterMiddleware.kDefaultTheaterId)) + .thenReturn(null); + + await middleware.call(null, InitAction(), next); + + final InitCompleteAction action = log.single; + Theater theater = action.selectedTheater; + expect(theater.id, '1033'); + expect(theater.name, 'Helsinki: Tennispalatsi'); + }, + ); + }); + + test( + 'when called with ChangeCurrentTheaterAction, persists and dispatches the same action', + () async { + final theater = Theater(id: 'test-123', name: 'Test Theater'); + await middleware.call(null, ChangeCurrentTheaterAction(theater), next); + + verify(mockKeyValueStore.setString( + TheaterMiddleware.kDefaultTheaterId, 'test-123')); + + final ChangeCurrentTheaterAction action = log.single; + expect(action.selectedTheater, theater); + }, + ); + }); +} + +Future theatersXml() => Future.value(''' + + + 1029 + Valitse alue/teatteri + + + 001 + Gotham: THEATER ONE + + + 002 + Gotham: THEATER TWO + +'''); diff --git a/core/test/utils/event_name_cleaner_test.dart b/core/test/utils/event_name_cleaner_test.dart new file mode 100644 index 00000000..92e747b2 --- /dev/null +++ b/core/test/utils/event_name_cleaner_test.dart @@ -0,0 +1,34 @@ +import 'package:core/src/utils/event_name_cleaner.dart'; +import 'package:test/test.dart'; + +void main() { + group('$EventNameCleaner', () { + String cleanup(String noise) => EventNameCleaner.cleanup(noise); + + test('cleans up unneeded noise from movie names', () { + expect(cleanup('Avengers: Infinity War (2D)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (3D)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War 2D'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War 3D'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (dub)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D dub)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D orig)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D spanish)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D) (dub)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D) (orig)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (2D) (spanish)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (3D dub)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (3D orig)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (3D spanish)'), 'Avengers: Infinity War'); + expect(cleanup('Avengers: Infinity War (swe)'), 'Avengers: Infinity War'); + expect(cleanup('Bohemian Rhapsody -erikoisnäytös'), 'Bohemian Rhapsody'); + expect(cleanup('Mamma Mia! Here We Go Again (SING-ALONG)'), 'Mamma Mia! Here We Go Again'); + expect(cleanup('Fantastic Beasts: The Crimes of Grindelwald - preview'), 'Fantastic Beasts: The Crimes of Grindelwald'); + + // These should stay the same + expect(cleanup('BPM (beats per minute)'), 'BPM (beats per minute)'); + expect(cleanup('That awesome spanish girl'), 'That awesome spanish girl'); + expect(cleanup('The 3D movie'), 'The 3D movie'); + }); + }); +} diff --git a/core/test/viewmodels/events_page_view_model_test.dart b/core/test/viewmodels/events_page_view_model_test.dart new file mode 100644 index 00000000..fff2d438 --- /dev/null +++ b/core/test/viewmodels/events_page_view_model_test.dart @@ -0,0 +1,48 @@ +import 'package:core/src/models/event.dart'; +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/viewmodels/events_page_view_model.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventsPageViewModel', () { + test('equal', () { + final first = EventsPageViewModel( + status: LoadingStatus.success, + events: [ + Event(id: 'abc123'), + ], + refreshEvents: () {}, + ); + + final second = EventsPageViewModel( + status: LoadingStatus.success, + events: [ + Event(id: 'abc123'), + ], + refreshEvents: () {}, + ); + + expect(first, second); + }); + + test('not equal', () { + final first = EventsPageViewModel( + status: LoadingStatus.success, + events: [ + Event(id: 'abc123'), + ], + refreshEvents: () {}, + ); + + final second = EventsPageViewModel( + status: LoadingStatus.success, + events: [ + Event(id: 'xyz456'), + ], + refreshEvents: () {}, + ); + + expect(first, isNot(second)); + }); + }); +} diff --git a/core/test/viewmodels/showtimes_page_view_model_test.dart b/core/test/viewmodels/showtimes_page_view_model_test.dart new file mode 100644 index 00000000..688feee0 --- /dev/null +++ b/core/test/viewmodels/showtimes_page_view_model_test.dart @@ -0,0 +1,60 @@ +import 'package:core/src/models/loading_status.dart'; +import 'package:core/src/models/show.dart'; +import 'package:core/src/viewmodels/showtime_page_view_model.dart'; +import 'package:test/test.dart'; + +void main() { + group('ShowtimesPageViewModel', () { + test('equal', () { + final first = ShowtimesPageViewModel( + status: LoadingStatus.success, + dates: [DateTime(2018)], + selectedDate: null, + shows: [ + Show(id: 'abc123'), + ], + changeCurrentDate: (DateTime newDate) {}, + refreshShowtimes: () {}, + ); + + final second = ShowtimesPageViewModel( + status: LoadingStatus.success, + dates: [DateTime(2018)], + selectedDate: null, + shows: [ + Show(id: 'abc123'), + ], + changeCurrentDate: (DateTime newDate) {}, + refreshShowtimes: () {}, + ); + + expect(first, second); + }); + + test('not equal', () { + final first = ShowtimesPageViewModel( + status: LoadingStatus.success, + dates: [DateTime(2018)], + selectedDate: null, + shows: [ + Show(id: 'abc123'), + ], + changeCurrentDate: (DateTime newDate) {}, + refreshShowtimes: () {}, + ); + + final second = ShowtimesPageViewModel( + status: LoadingStatus.success, + dates: [DateTime(2018)], + selectedDate: null, + shows: [ + Show(id: 'xyz456'), + ], + changeCurrentDate: (DateTime newDate) {}, + refreshShowtimes: () {}, + ); + + expect(first, isNot(second)); + }); + }); +} diff --git a/core/test/viewmodels/theater_list_view_model_test.dart b/core/test/viewmodels/theater_list_view_model_test.dart new file mode 100644 index 00000000..0337ff12 --- /dev/null +++ b/core/test/viewmodels/theater_list_view_model_test.dart @@ -0,0 +1,51 @@ +import 'package:core/src/models/theater.dart'; +import 'package:core/src/viewmodels/theater_list_view_model.dart'; +import 'package:test/test.dart'; + +void main() { + group('TheaterListViewModel', () { + test('equal', () { + final first = TheaterListViewModel( + currentTheater: Theater(id: 'abc123', name: 'Test 1'), + theaters: [ + Theater(id: 'abc123', name: 'Test 1'), + Theater(id: 'xyz456', name: 'Test 2'), + ], + changeCurrentTheater: (Theater newTheater) {}, + ); + + final second = TheaterListViewModel( + currentTheater: Theater(id: 'abc123', name: 'Test 1'), + theaters: [ + Theater(id: 'abc123', name: 'Test 1'), + Theater(id: 'xyz456', name: 'Test 2'), + ], + changeCurrentTheater: (Theater newTheater) {}, + ); + + expect(first, second); + }); + + test('not equal', () { + final first = TheaterListViewModel( + currentTheater: Theater(id: 'abc123', name: 'Test 1'), + theaters: [ + Theater(id: 'abc123', name: 'Test 1'), + Theater(id: 'xyz456', name: 'Test 2'), + ], + changeCurrentTheater: (Theater newTheater) {}, + ); + + final second = TheaterListViewModel( + currentTheater: Theater(id: 'abc123', name: 'Test 1'), + theaters: [ + Theater(id: 'efg123', name: 'Test 3'), + Theater(id: 'hjk456', name: 'Test 4'), + ], + changeCurrentTheater: (Theater newTheater) {}, + ); + + expect(first, isNot(second)); + }); + }); +} diff --git a/inkino.iml b/inkino.iml deleted file mode 100644 index a1f9fee5..00000000 --- a/inkino.iml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/inkino_android.iml b/inkino_android.iml deleted file mode 100644 index 0ca70ed9..00000000 --- a/inkino_android.iml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/lib/data/models/event.dart b/lib/data/models/event.dart deleted file mode 100644 index 81ec551a..00000000 --- a/lib/data/models/event.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/utils/event_name_cleaner.dart'; -import 'package:inkino/utils/xml_utils.dart'; -import 'package:meta/meta.dart'; -import 'package:xml/xml.dart' as xml; - -enum EventListType { - nowInTheaters, - comingSoon, -} - -class Event { - Event({ - this.id, - this.title, - this.originalTitle, - this.genres, - this.directors, - this.actors, - this.lengthInMinutes, - this.shortSynopsis, - this.synopsis, - this.images, - this.youtubeTrailers, - }); - - final String id; - final String title; - final String originalTitle; - final String genres; - final List directors; - final String lengthInMinutes; - final String shortSynopsis; - final String synopsis; - final EventImageData images; - final List youtubeTrailers; - - List actors; - - bool get hasSynopsis => - (shortSynopsis != null && shortSynopsis.isNotEmpty) || - (synopsis != null && synopsis.isNotEmpty); - - static List parseAll(String xmlString) { - var events = []; - var document = xml.parse(xmlString); - - document.findAllElements('Event').forEach((node) { - var title = tagContents(node, 'Title'); - var originalTitle = tagContents(node, 'OriginalTitle'); - - events.add(new Event( - id: tagContents(node, 'ID'), - title: EventNameCleaner.cleanup(title), - originalTitle: EventNameCleaner.cleanup(originalTitle), - genres: tagContents(node, 'Genres'), - directors: _parseDirectors(node.findAllElements('Director')), - actors: _parseActors(node.findAllElements('Actor')), - lengthInMinutes: tagContents(node, 'LengthInMinutes'), - shortSynopsis: tagContents(node, 'ShortSynopsis'), - synopsis: tagContents(node, 'Synopsis'), - images: EventImageData.parseAll(node.findElements('Images')), - youtubeTrailers: _parseTrailers(node.findAllElements('EventVideo')), - )); - }); - - return events; - } - - static List _parseDirectors(Iterable nodes) { - var directors = []; - - nodes.forEach((node) { - var first = tagContents(node, 'FirstName'); - var last = tagContents(node, 'LastName'); - directors.add('$first $last'); - }); - - return directors; - } - - static List _parseActors(Iterable nodes) { - var actors = []; - - nodes.forEach((node) { - var first = tagContents(node, 'FirstName'); - var last = tagContents(node, 'LastName'); - actors.add(new Actor(name: '$first $last')); - }); - - return actors; - } - - static List _parseTrailers(Iterable nodes) { - var trailers = []; - - nodes.forEach((node) { - trailers.add( - 'https://youtube.com/watch?v=' + tagContents(node, 'Location'), - ); - }); - - return trailers; - } -} - -class EventImageData { - EventImageData({ - @required this.portraitSmall, - @required this.portraitMedium, - @required this.portraitLarge, - @required this.landscapeSmall, - @required this.landscapeBig, - }); - - final String portraitSmall; - final String portraitMedium; - final String portraitLarge; - final String landscapeSmall; - final String landscapeBig; - - String get anyAvailableImage => - portraitSmall ?? - portraitMedium ?? - portraitLarge ?? - landscapeSmall ?? - landscapeBig; - - EventImageData.empty() - : portraitSmall = null, - portraitMedium = null, - portraitLarge = null, - landscapeSmall = null, - landscapeBig = null; - - static EventImageData parseAll(Iterable roots) { - if (roots == null || roots.isEmpty) { - return new EventImageData.empty(); - } - - var root = roots.first; - - return new EventImageData( - portraitSmall: tagContentsOrNull(root, 'EventSmallImagePortrait'), - portraitMedium: tagContentsOrNull(root, 'EventMediumImagePortrait'), - portraitLarge: tagContentsOrNull(root, 'EventLargeImagePortrait'), - landscapeSmall: tagContentsOrNull(root, 'EventSmallImageLandscape'), - landscapeBig: tagContentsOrNull(root, 'EventLargeImageLandscape'), - ); - } -} diff --git a/lib/data/models/show.dart b/lib/data/models/show.dart deleted file mode 100644 index 4f9e5e68..00000000 --- a/lib/data/models/show.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:inkino/utils/event_name_cleaner.dart'; -import 'package:inkino/utils/xml_utils.dart'; -import 'package:xml/xml.dart' as xml; - -class Show { - Show({ - this.id, - this.eventId, - this.title, - this.originalTitle, - this.url, - this.presentationMethod, - this.theaterAndAuditorium, - this.start, - this.end, - }); - - final String id; - final String eventId; - final String title; - final String originalTitle; - final String url; - final String presentationMethod; - final String theaterAndAuditorium; - final DateTime start; - final DateTime end; - - static List parseAll(String xmlString) { - var shows = []; - var document = xml.parse(xmlString); - - document.findAllElements('Show').forEach((node) { - var title = tagContents(node, 'Title'); - var originalTitle = tagContents(node, 'OriginalTitle'); - - shows.add(new Show( - id: tagContents(node, 'ID'), - eventId: tagContents(node, 'EventID'), - title: EventNameCleaner.cleanup(title), - originalTitle: EventNameCleaner.cleanup(originalTitle), - url: tagContents(node, 'ShowURL'), - presentationMethod: tagContents(node, 'PresentationMethod'), - theaterAndAuditorium: tagContents(node, 'TheatreAndAuditorium'), - start: DateTime.parse(tagContents(node, 'dttmShowStart')), - end: DateTime.parse(tagContents(node, 'dttmShowEnd')), - )); - }); - - return shows; - } -} diff --git a/lib/data/networking/finnkino_api.dart b/lib/data/networking/finnkino_api.dart deleted file mode 100644 index 7f211f2f..00000000 --- a/lib/data/networking/finnkino_api.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:async'; - -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/data/networking/http_utils.dart'; -import 'package:intl/intl.dart'; - -class FinnkinoApi { - static final DateFormat ddMMyyyy = new DateFormat('dd.MM.yyyy'); - - static final Uri kScheduleBaseUrl = - new Uri.https('www.finnkino.fi', '/en/xml/Schedule'); - static final Uri kEventsBaseUrl = - new Uri.https('www.finnkino.fi', '/en/xml/Events'); - - Future> getSchedule(Theater theater, DateTime date) async { - var dt = ddMMyyyy.format(date ?? new DateTime.now()); - var response = await getRequest( - kScheduleBaseUrl.replace(queryParameters: { - 'area': theater.id, - 'dt': dt, - }), - ); - - return Show.parseAll(response); - } - - Future> getNowInTheatersEvents(Theater theater) async { - var response = await getRequest( - kEventsBaseUrl.replace(queryParameters: { - 'area': theater.id, - 'listType': 'NowInTheatres', - }), - ); - - return Event.parseAll(response); - } - - Future> getUpcomingEvents() async { - var response = await getRequest( - kEventsBaseUrl.replace(queryParameters: { - 'listType': 'ComingSoon', - }), - ); - - return Event.parseAll(response); - } -} diff --git a/lib/data/networking/tmdb_api.dart b/lib/data/networking/tmdb_api.dart deleted file mode 100644 index 61f97d79..00000000 --- a/lib/data/networking/tmdb_api.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/networking/http_utils.dart'; -/// If this has a red underline, it means that you haven't created -/// the lib/tmdb_config.dart file. Refer to the README for instructions -/// on how to do so. -import 'package:inkino/tmdb_config.dart'; -/// If this has a red underline, it means that you haven't created -/// the lib/tmdb_config.dart file. Refer to the README for instructions -/// on how to do so. - - -class TMDBApi { - static final String baseUrl = 'api.themoviedb.org'; - - Future> findAvatarsForActors( - Event event, List actors) async { - int movieId = await _findMovieId(event.originalTitle); - - if (movieId != null) { - return _getActorAvatars(movieId); - } - - return actors; - } - - Future _findMovieId(String movieTitle) async { - var searchUri = new Uri.https( - baseUrl, - '3/search/movie', - { - 'api_key': TMDBConfig.apiKey, - 'query': movieTitle, - }, - ); - - var response = await getRequest(searchUri); - var movieSearchJson = json.decode(response); - List> searchResults = movieSearchJson['results']; - - if (searchResults.isNotEmpty) { - return searchResults.first['id']; - } - - return null; - } - - Future> _getActorAvatars(int movieId) async { - var actorUri = new Uri.https( - baseUrl, - '3/movie/$movieId/credits', - {'api_key': TMDBConfig.apiKey}, - ); - - var response = await getRequest(actorUri); - var movieActors = json.decode(response); - - return _parseActorAvatars(movieActors['cast']); - } - - List _parseActorAvatars(List> movieCast) { - var actorsWithAvatars = []; - - movieCast.forEach((castMember) { - var pp = castMember['profile_path']; - var profilePath = - pp != null ? 'https://image.tmdb.org/t/p/w200$pp' : null; - - actorsWithAvatars.add(new Actor( - name: castMember['name'], - avatarUrl: profilePath, - )); - }); - - return actorsWithAvatars; - } -} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index d5b11bec..00000000 --- a/lib/main.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/store.dart'; -import 'package:inkino/ui/main_page.dart'; -import 'package:redux/redux.dart'; - -Future main() async { - // ignore: deprecated_member_use - MaterialPageRoute.debugEnableFadingRoutes = true; - - var store = await createStore(); - runApp(new InKinoApp(store)); -} - -class InKinoApp extends StatefulWidget { - InKinoApp(this.store); - final Store store; - - @override - _InKinoAppState createState() => new _InKinoAppState(); -} - -class _InKinoAppState extends State { - @override - void initState() { - super.initState(); - widget.store.dispatch(new InitAction()); - } - - @override - Widget build(BuildContext context) { - return new StoreProvider( - store: widget.store, - child: new MaterialApp( - title: 'inKino', - theme: new ThemeData( - primaryColor: new Color(0xFF1C306D), - accentColor: new Color(0xFFFFAD32), - ), - home: new MainPage(), - ), - ); - } -} diff --git a/lib/redux/app/app_middleware.dart b/lib/redux/app/app_middleware.dart deleted file mode 100644 index b4f2e0ff..00000000 --- a/lib/redux/app/app_middleware.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; - -import 'package:inkino/data/networking/tmdb_api.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:redux/redux.dart'; - -class AppMiddleware extends MiddlewareClass { - AppMiddleware(this.tmdbApi); - final TMDBApi tmdbApi; - - @override - Future call(Store store, action, NextDispatcher next) async { - next(action); - - if (action is FetchActorAvatarsAction) { - next(new ActorsUpdatedAction(action.event.actors)); - - try { - var actorsWithAvatars = await tmdbApi.findAvatarsForActors( - action.event, - action.event.actors, - ); - - // TMDB API might have a more comprehensive list of actors than the - // Finnkino API, so we update the event with the actors we get from - // the TMDB API. - next(new UpdateActorsForEventAction(action.event, actorsWithAvatars)); - next(new ReceivedActorAvatarsAction(actorsWithAvatars)); - } catch (e) { - // YOLO! We don't need to handle this. If fetching actor avatars - // fails, we don't care. - } - } - } -} diff --git a/lib/redux/app/app_reducer.dart b/lib/redux/app/app_reducer.dart deleted file mode 100644 index 4841a57f..00000000 --- a/lib/redux/app/app_reducer.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/event/event_reducer.dart'; -import 'package:inkino/redux/show/show_reducer.dart'; -import 'package:inkino/redux/theater/theater_reducer.dart'; -import 'package:redux/redux.dart'; - -AppState appReducer(AppState state, dynamic action) { - return new AppState( - searchQuery: _searchQueryReducer(state.searchQuery, action), - actorsByName: actorReducer(state.actorsByName, action), - theaterState: theaterReducer(state.theaterState, action), - showState: showReducer(state.showState, action), - eventState: eventReducer(state.eventState, action), - ); -} - -String _searchQueryReducer(String searchQuery, action) { - if (action is SearchQueryChangedAction) { - return action.query; - } - - return searchQuery; -} - -final actorReducer = combineTypedReducers>([ - new ReducerBinding, ActorsUpdatedAction>(_actorsUpdated), - new ReducerBinding, ReceivedActorAvatarsAction>(_receivedAvatars), -]); - -Map _actorsUpdated(Map actorsByName, action) { - var actors = {}..addAll(actorsByName); - action.actors.forEach((actor) { - actors.putIfAbsent(actor.name, () => new Actor(name: actor.name)); - }); - - return actors; -} - -Map _receivedAvatars(Map actorsByName, action) { - var actorsWithAvatars = {}..addAll(actorsByName); - action.actors.forEach((actor) { - actorsWithAvatars[actor.name] = new Actor( - name: actor.name, - avatarUrl: actor.avatarUrl, - ); - }); - - return actorsWithAvatars; -} diff --git a/lib/redux/event/event_actions.dart b/lib/redux/event/event_actions.dart deleted file mode 100644 index 7686c66f..00000000 --- a/lib/redux/event/event_actions.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/theater.dart'; - -class RefreshEventsAction {} - -class FetchEventsAction { - FetchEventsAction(this.theater); - final Theater theater; -} - -class RequestingEventsAction {} - -class ReceivedEventsAction { - ReceivedEventsAction({ - @required this.nowInTheatersEvents, - @required this.comingSoonEvents, - }); - - final List nowInTheatersEvents; - final List comingSoonEvents; -} - -class ErrorLoadingEventsAction {} diff --git a/lib/redux/event/event_middleware.dart b/lib/redux/event/event_middleware.dart deleted file mode 100644 index 5d7ce4cc..00000000 --- a/lib/redux/event/event_middleware.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/data/networking/finnkino_api.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/event/event_actions.dart'; -import 'package:redux/redux.dart'; - -class EventMiddleware extends MiddlewareClass { - EventMiddleware(this.api); - final FinnkinoApi api; - - @override - Future call(Store store, action, NextDispatcher next) async { - next(action); - - if (action is InitCompleteAction || - action is ChangeCurrentTheaterAction || - action is RefreshEventsAction) { - var theater = _determineTheater(action, store); - - if (theater != null) { - await _fetchEvents(theater, next); - } - } - } - - Theater _determineTheater(dynamic action, Store store) { - if (action is RefreshEventsAction) { - return store.state.theaterState.currentTheater; - } - - return action.selectedTheater; - } - - Future _fetchEvents( - Theater newTheater, - NextDispatcher next, - ) async { - next(new RequestingEventsAction()); - - try { - var inTheatersEvents = await api.getNowInTheatersEvents(newTheater); - var comingSoonEvents = await api.getUpcomingEvents(); - - next(new ReceivedEventsAction( - nowInTheatersEvents: inTheatersEvents, - comingSoonEvents: comingSoonEvents, - )); - } catch (e) { - next(new ErrorLoadingEventsAction()); - } - } -} diff --git a/lib/redux/event/event_reducer.dart b/lib/redux/event/event_reducer.dart deleted file mode 100644 index cfeb69aa..00000000 --- a/lib/redux/event/event_reducer.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/event/event_actions.dart'; -import 'package:inkino/redux/event/event_state.dart'; -import 'package:redux/redux.dart'; - -final eventReducer = combineTypedReducers([ - new ReducerBinding(_requestingEvents), - new ReducerBinding(_receivedEvents), - new ReducerBinding(_errorLoadingEvents), - new ReducerBinding( - _updateActorsForEvent), -]); - -EventState _requestingEvents(EventState state, RequestingEventsAction action) { - return state.copyWith(loadingStatus: LoadingStatus.loading); -} - -EventState _receivedEvents(EventState state, ReceivedEventsAction action) { - return state.copyWith( - loadingStatus: LoadingStatus.success, - nowInTheatersEvents: action.nowInTheatersEvents, - comingSoonEvents: action.comingSoonEvents, - ); -} - -EventState _errorLoadingEvents( - EventState state, ErrorLoadingEventsAction action) { - return state.copyWith(loadingStatus: LoadingStatus.error); -} - -EventState _updateActorsForEvent( - EventState state, UpdateActorsForEventAction action) { - var event = action.event; - event.actors = action.actors; - - var inTheatersEvents = []..addAll(state.nowInTheatersEvents); - var comingSoonEvents = []..addAll(state.comingSoonEvents); - - var inTheatersMatch = inTheatersEvents.indexWhere((e) => e.id == event.id); - - if (inTheatersMatch > -1) { - inTheatersEvents[inTheatersMatch] = event; - } else { - var comingSoonMatch = comingSoonEvents.indexWhere((e) => e.id == event.id); - - if (comingSoonMatch > -1) { - comingSoonEvents[comingSoonMatch] = event; - } - } - - return state.copyWith( - nowInTheatersEvents: inTheatersEvents, - comingSoonEvents: comingSoonEvents, - ); -} diff --git a/lib/redux/event/event_selectors.dart b/lib/redux/event/event_selectors.dart deleted file mode 100644 index 71e64642..00000000 --- a/lib/redux/event/event_selectors.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:collection'; - -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/redux/app/app_state.dart'; - -List eventsSelector(AppState state, EventListType type) { - List events = type == EventListType.nowInTheaters - ? state.eventState.nowInTheatersEvents - : state.eventState.comingSoonEvents; - - var uniqueEvents = _uniqueEvents(events); - - if (state.searchQuery == null) { - return uniqueEvents; - } - - return _eventsWithSearchQuery(state, uniqueEvents); -} - -List _uniqueEvents(List original) { - var uniqueEventMap = new LinkedHashMap(); - original.forEach((event) { - uniqueEventMap[event.originalTitle] = event; - }); - - return uniqueEventMap.values.toList(); -} - -List _eventsWithSearchQuery(AppState state, List original) { - var searchQuery = new RegExp(state.searchQuery, caseSensitive: false); - - return original.where((event) { - return event.title.contains(searchQuery) || - event.originalTitle.contains(searchQuery); - }).toList(); -} - -Event eventForShowSelector(AppState state, Show show) { - return state.eventState.nowInTheatersEvents - .where((event) => event.id == show.eventId) - .first; -} diff --git a/lib/redux/event/event_state.dart b/lib/redux/event/event_state.dart deleted file mode 100644 index 18462e8d..00000000 --- a/lib/redux/event/event_state.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:meta/meta.dart'; - -@immutable -class EventState { - EventState({ - @required this.loadingStatus, - @required this.nowInTheatersEvents, - @required this.comingSoonEvents, - }); - - final LoadingStatus loadingStatus; - final List nowInTheatersEvents; - final List comingSoonEvents; - - factory EventState.initial() { - return new EventState( - loadingStatus: LoadingStatus.loading, - nowInTheatersEvents: [], - comingSoonEvents: [], - ); - } - - EventState copyWith({ - LoadingStatus loadingStatus, - List nowInTheatersEvents, - List comingSoonEvents, - }) { - return new EventState( - loadingStatus: loadingStatus ?? this.loadingStatus, - nowInTheatersEvents: nowInTheatersEvents ?? this.nowInTheatersEvents, - comingSoonEvents: comingSoonEvents ?? this.comingSoonEvents, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is EventState && - runtimeType == other.runtimeType && - loadingStatus == other.loadingStatus && - nowInTheatersEvents == other.nowInTheatersEvents && - comingSoonEvents == other.comingSoonEvents; - - @override - int get hashCode => - loadingStatus.hashCode ^ - nowInTheatersEvents.hashCode ^ - comingSoonEvents.hashCode; -} diff --git a/lib/redux/show/show_actions.dart b/lib/redux/show/show_actions.dart deleted file mode 100644 index c36c9f24..00000000 --- a/lib/redux/show/show_actions.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/data/models/theater.dart'; - -class FetchShowsAction { - FetchShowsAction(this.theater); - final Theater theater; -} - -class RequestingShowsAction {} - -class RefreshShowsAction {} - -class ReceivedShowsAction { - ReceivedShowsAction(this.theater, this.shows); - - final Theater theater; - final List shows; -} - -class ErrorLoadingShowsAction {} - -class ChangeCurrentDateAction { - ChangeCurrentDateAction(this.date); - final DateTime date; -} diff --git a/lib/redux/show/show_middleware.dart b/lib/redux/show/show_middleware.dart deleted file mode 100644 index 80eb6f47..00000000 --- a/lib/redux/show/show_middleware.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:async'; - -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/data/networking/finnkino_api.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/show/show_actions.dart'; -import 'package:inkino/utils/clock.dart'; -import 'package:redux/redux.dart'; - -class ShowMiddleware extends MiddlewareClass { - ShowMiddleware(this.api); - - final FinnkinoApi api; - - @override - Future call(Store store, action, NextDispatcher next) async { - next(action); - - if (action is InitCompleteAction || - action is ChangeCurrentTheaterAction || - action is RefreshShowsAction || - action is ChangeCurrentDateAction) { - await _handleShowsUpdate(store, action, next); - } - } - - Future _handleShowsUpdate( - Store store, dynamic action, NextDispatcher next) async { - Theater theater = store.state.theaterState.currentTheater; - DateTime date; - - if (action is InitCompleteAction || action is ChangeCurrentTheaterAction) { - theater = action.selectedTheater; - } else if (action is ChangeCurrentDateAction) { - date = action.date; - } - - next(new RequestingShowsAction()); - - try { - var shows = await _fetchShows(theater, date, next); - next(new ReceivedShowsAction(theater, shows)); - } catch(e) { - next(new ErrorLoadingShowsAction()); - } - } - - Future> _fetchShows( - Theater newTheater, - DateTime currentDate, - NextDispatcher next, - ) async { - var shows = await api.getSchedule(newTheater, currentDate); - var now = Clock.getCurrentTime(); - - // Return only show times that haven't started yet. - var relevantShows = shows.where((show) { - return show.start.isAfter(now); - }).toList(); - - return relevantShows; - } -} diff --git a/lib/redux/show/show_reducer.dart b/lib/redux/show/show_reducer.dart deleted file mode 100644 index 9020bc96..00000000 --- a/lib/redux/show/show_reducer.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/redux/show/show_actions.dart'; -import 'package:inkino/redux/show/show_state.dart'; -import 'package:redux/redux.dart'; - -final showReducer = combineTypedReducers([ - new ReducerBinding(_changeTheater), - new ReducerBinding(_changeDate), - new ReducerBinding(_requestingShows), - new ReducerBinding(_receivedShows), - new ReducerBinding(_errorLoadingShows), -]); - -ShowState _changeTheater(ShowState state, _) { - return state.copyWith(selectedDate: state.dates.first); -} - -ShowState _changeDate(ShowState state, ChangeCurrentDateAction action) { - return state.copyWith(selectedDate: action.date); -} - -ShowState _requestingShows(ShowState state, RequestingShowsAction action) { - return state.copyWith(loadingStatus: LoadingStatus.loading); -} - -ShowState _receivedShows(ShowState state, ReceivedShowsAction action) { - return state.copyWith( - loadingStatus: LoadingStatus.success, - shows: action.shows, - ); -} - -ShowState _errorLoadingShows(ShowState state, ErrorLoadingShowsAction action) { - return state.copyWith(loadingStatus: LoadingStatus.error); -} diff --git a/lib/redux/show/show_selectors.dart b/lib/redux/show/show_selectors.dart deleted file mode 100644 index 6735b778..00000000 --- a/lib/redux/show/show_selectors.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/redux/app/app_state.dart'; - -List showsSelector(AppState state) { - var shows = state.showState.shows; - - if (state.searchQuery == null) { - return shows; - } - - return _showsWithSearchQuery(state, shows); -} - -List _showsWithSearchQuery(AppState state, List shows) { - var searchQuery = new RegExp(state.searchQuery, caseSensitive: false); - - return shows.where((show) { - return show.title.contains(searchQuery) || - show.originalTitle.contains(searchQuery); - }).toList(); -} diff --git a/lib/redux/store.dart b/lib/redux/store.dart deleted file mode 100644 index a58967e6..00000000 --- a/lib/redux/store.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:inkino/data/networking/finnkino_api.dart'; -import 'package:inkino/data/networking/tmdb_api.dart'; -import 'package:inkino/redux/app/app_middleware.dart'; -import 'package:inkino/redux/app/app_reducer.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/event/event_middleware.dart'; -import 'package:inkino/redux/show/show_middleware.dart'; -import 'package:inkino/redux/theater/theater_middleware.dart'; -import 'package:redux/redux.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -Future> createStore() async { - var tmdbApi = new TMDBApi(); - var finnkinoApi = new FinnkinoApi(); - var prefs = await SharedPreferences.getInstance(); - - return new Store( - appReducer, - initialState: new AppState.initial(), - distinct: true, - middleware: [ - new AppMiddleware(tmdbApi), - new TheaterMiddleware(rootBundle, prefs), - new ShowMiddleware(finnkinoApi), - new EventMiddleware(finnkinoApi), - ], - ); -} diff --git a/lib/redux/theater/theater_middleware.dart b/lib/redux/theater/theater_middleware.dart deleted file mode 100644 index 7cda0c68..00000000 --- a/lib/redux/theater/theater_middleware.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:inkino/assets.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:redux/redux.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class TheaterMiddleware extends MiddlewareClass { - static const String kDefaultTheaterId = 'default_theater_id'; - - final AssetBundle bundle; - final SharedPreferences preferences; - - TheaterMiddleware(this.bundle, this.preferences); - - @override - Future call(Store store, action, NextDispatcher next) async { - if (action is InitAction) { - await _init(action, next); - } else if (action is ChangeCurrentTheaterAction) { - await _changeCurrentTheater(action, next); - } else { - next(action); - } - } - - Future _init( - InitAction action, - NextDispatcher next, - ) async { - var theaterXml = await bundle.loadString(OtherAssets.preloadedTheaters); - var theaters = Theater.parseAll(theaterXml); - var currentTheater = _getDefaultTheater(theaters); - - next(new InitCompleteAction(theaters, currentTheater)); - } - - Future _changeCurrentTheater( - ChangeCurrentTheaterAction action, - NextDispatcher next, - ) async { - preferences.setString(kDefaultTheaterId, action.selectedTheater.id); - next(action); - } - - Theater _getDefaultTheater(List allTheaters) { - var persistedTheaterId = preferences.getString(kDefaultTheaterId); - - if (persistedTheaterId != null) { - return allTheaters.singleWhere((theater) { - return theater.id == persistedTheaterId; - }); - } - - return allTheaters.first; - } -} diff --git a/lib/redux/theater/theater_reducer.dart b/lib/redux/theater/theater_reducer.dart deleted file mode 100644 index 060e9345..00000000 --- a/lib/redux/theater/theater_reducer.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:inkino/redux/common_actions.dart'; -import 'package:redux/redux.dart'; -import 'package:inkino/redux/theater/theater_state.dart'; - -final theaterReducer = combineTypedReducers([ - new ReducerBinding(_initComplete), - new ReducerBinding( - _currentTheaterChanged), -]); - -TheaterState _initComplete(TheaterState state, InitCompleteAction action) { - return state.copyWith( - currentTheater: action.selectedTheater, - theaters: action.theaters, - ); -} - -TheaterState _currentTheaterChanged( - TheaterState state, ChangeCurrentTheaterAction action) { - return state.copyWith(currentTheater: action.selectedTheater); -} diff --git a/lib/ui/common/info_message_view.dart b/lib/ui/common/info_message_view.dart deleted file mode 100644 index 6461d8cc..00000000 --- a/lib/ui/common/info_message_view.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; - -class ErrorView extends InfoMessageView { - static final Key tryAgainButtonKey = new Key('tryAgainButton'); - - ErrorView({ - String title, - String description, - @required VoidCallback onRetry, - }) - : super( - actionButtonKey: tryAgainButtonKey, - title: title ?? 'Oops!', - description: - description ?? 'There was an error while\nloading movies.', - onActionButtonTapped: onRetry, - ); -} - -class InfoMessageView extends StatelessWidget { - InfoMessageView({ - Key key, - @required this.title, - @required this.description, - this.actionButtonKey, - this.onActionButtonTapped, - }) - : super(key: key); - - final Key actionButtonKey; - final String title; - final String description; - final VoidCallback onActionButtonTapped; - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var content = [ - new CircleAvatar( - child: new Icon( - Icons.info_outline, - color: Colors.black54, - size: 52.0, - ), - backgroundColor: Colors.black12, - radius: 42.0, - ), - new Padding( - padding: const EdgeInsets.only(top: 16.0), - child: new Text( - title, - style: new TextStyle(fontSize: 24.0), - ), - ), - new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: new Text( - description, - textAlign: TextAlign.center, - ), - ), - ]; - - if (onActionButtonTapped != null) { - content.add(new Padding( - padding: const EdgeInsets.only(top: 12.0), - child: new FlatButton( - key: actionButtonKey, - onPressed: onActionButtonTapped, - child: new Text( - 'TRY AGAIN', - style: new TextStyle(color: theme.primaryColor), - ), - ), - )); - } - - return new SingleChildScrollView( - child: new Container( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: new Center( - child: new Column( - mainAxisAlignment: MainAxisAlignment.center, - children: content, - ), - ), - ), - ); - } -} diff --git a/lib/ui/event_details/actor_scroller.dart b/lib/ui/event_details/actor_scroller.dart deleted file mode 100644 index 8b28d4ef..00000000 --- a/lib/ui/event_details/actor_scroller.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/assets.dart'; -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_selectors.dart'; -import 'package:inkino/redux/app/app_state.dart'; - -class ActorScroller extends StatelessWidget { - ActorScroller(this.event); - final Event event; - - @override - Widget build(BuildContext context) { - return new StoreConnector>( - onInit: (store) => store.dispatch(new FetchActorAvatarsAction(event)), - converter: (store) => actorsForEventSelector(store.state, event), - builder: (_, actors) => new ActorScrollerContent(actors), - ); - } -} - -class ActorScrollerContent extends StatelessWidget { - ActorScrollerContent(this.actors); - final List actors; - - Widget _buildActorList(BuildContext context) { - return new ListView.builder( - padding: const EdgeInsets.only(left: 16.0), - scrollDirection: Axis.horizontal, - itemCount: actors.length, - itemBuilder: (BuildContext context, int index) { - var actor = actors[index]; - return _buildActorListItem(context, actor); - }, - ); - } - - Widget _buildActorListItem(BuildContext context, Actor actor) { - var actorName = new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: new Text( - actor.name, - style: new TextStyle(fontSize: 12.0), - textAlign: TextAlign.center, - ), - ); - - return new Container( - width: 90.0, - padding: const EdgeInsets.only(right: 16.0), - child: new Column( - children: [ - _buildActorAvatar(context, actor), - actorName, - ], - ), - ); - } - - Widget _buildActorAvatar(BuildContext context, Actor actor) { - var fallbackIcon = new Icon( - Icons.person, - color: Colors.white, - size: 26.0, - ); - - var avatarImage = new ClipOval( - child: new FadeInImage.assetNetwork( - placeholder: ImageAssets.transparentImage, - // FIXME: The example.com here is a hack to not make the - // FadeInImage crash when there's no avatar url for - // the actor. - image: actor.avatarUrl ?? 'https://example.com', - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 250), - ), - ); - - return new Container( - width: 56.0, - height: 56.0, - decoration: new BoxDecoration( - color: Theme.of(context).primaryColor, - shape: BoxShape.circle, - ), - child: new Stack( - alignment: Alignment.center, - fit: StackFit.expand, - children: [ - fallbackIcon, - avatarImage, - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return new Container( - padding: const EdgeInsets.only(top: 16.0), - child: new Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Padding( - padding: const EdgeInsets.only(left: 16.0), - child: new Text( - 'Cast', - style: new TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.w700, - ), - ), - ), - new Padding( - padding: const EdgeInsets.only(top: 16.0), - child: new SizedBox.fromSize( - size: new Size.fromHeight(110.0), - child: _buildActorList(context), - ), - ), - ], - ), - ); - } -} diff --git a/lib/ui/event_details/event_backdrop_photo.dart b/lib/ui/event_details/event_backdrop_photo.dart deleted file mode 100644 index 275d35ab..00000000 --- a/lib/ui/event_details/event_backdrop_photo.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:inkino/assets.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/utils/widget_utils.dart'; -import 'package:meta/meta.dart'; - -class EventBackdropPhoto extends StatelessWidget { - EventBackdropPhoto({ - @required this.event, - @required this.height, - @required this.overlayBlur, - @required this.blurOverlayOpacity, - }); - - final Event event; - final double height; - final double overlayBlur; - final double blurOverlayOpacity; - - Widget _buildBackdropPhotoWithPlaceholder(BuildContext context) { - var content = [ - _buildPlaceholderBackground(context), - ]; - - addIfNonNull(_buildBackdropPhoto(context), content); - - return new Stack( - alignment: Alignment.center, - children: content, - ); - } - - Widget _buildPlaceholderBackground(BuildContext context) { - return new Container( - width: MediaQuery.of(context).size.width, - height: height, - decoration: new BoxDecoration( - gradient: new LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - const Color(0xFF222222), - const Color(0xFF424242), - ], - ), - ), - child: new Center( - child: new Icon( - Icons.theaters, - color: Colors.white30, - size: 96.0, - ), - ), - ); - } - - Widget _buildBackdropPhoto(BuildContext context) { - var photoUrl = event.images.landscapeBig ?? event.images.landscapeSmall; - - if (photoUrl != null) { - var screenWidth = MediaQuery.of(context).size.width; - - return new SizedBox( - width: screenWidth, - height: height, - child: new FadeInImage.assetNetwork( - placeholder: ImageAssets.transparentImage, - image: photoUrl, - width: screenWidth, - height: height, - fadeInDuration: const Duration(milliseconds: 300), - fit: BoxFit.cover, - ), - ); - } - - return null; - } - - Widget _buildBlurOverlay(BuildContext context) { - return new BackdropFilter( - filter: new ui.ImageFilter.blur( - sigmaX: overlayBlur, - sigmaY: overlayBlur, - ), - child: new Container( - width: MediaQuery.of(context).size.width, - height: height, - decoration: new BoxDecoration( - color: Colors.black.withOpacity(blurOverlayOpacity * 0.4), - ), - ), - ); - } - - Widget _buildShadowInset(BuildContext context) { - return new Positioned( - bottom: -8.0, - child: new DecoratedBox( - decoration: new BoxDecoration( - boxShadow: [ - new BoxShadow( - color: Colors.black38, - blurRadius: 5.0, - spreadRadius: 3.0, - ), - ], - ), - child: new SizedBox( - width: MediaQuery.of(context).size.width, - height: 10.0, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return new ClipRect( - child: new Stack( - children: [ - _buildBackdropPhotoWithPlaceholder(context), - _buildBlurOverlay(context), - _buildShadowInset(context), - ], - ), - ); - } -} diff --git a/lib/ui/event_details/event_details_page.dart b/lib/ui/event_details/event_details_page.dart deleted file mode 100644 index e972b628..00000000 --- a/lib/ui/event_details/event_details_page.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/ui/event_details/actor_scroller.dart'; -import 'package:inkino/ui/event_details/event_backdrop_photo.dart'; -import 'package:inkino/ui/event_details/event_details_scroll_effects.dart'; -import 'package:inkino/ui/event_details/showtime_information.dart'; -import 'package:inkino/ui/event_details/storyline_widget.dart'; -import 'package:inkino/ui/events/event_poster.dart'; -import 'package:inkino/utils/widget_utils.dart'; - -class EventDetailsPage extends StatefulWidget { - EventDetailsPage( - this.event, { - this.show, - }); - - final Event event; - final Show show; - - @override - _EventDetailsPageState createState() => new _EventDetailsPageState(); -} - -class _EventDetailsPageState extends State { - ScrollController _scrollController; - EventDetailsScrollEffects _scrollEffects; - - @override - void initState() { - super.initState(); - _scrollController = new ScrollController(); - _scrollController.addListener(_scrollListener); - _scrollEffects = new EventDetailsScrollEffects(); - } - - @override - void dispose() { - _scrollController.removeListener(_scrollListener); - _scrollController.dispose(); - super.dispose(); - } - - void _scrollListener() { - setState(() { - _scrollEffects.updateScrollOffset(context, _scrollController.offset); - }); - } - - Widget _buildHeader(BuildContext context) { - return new Stack( - children: [ - // Transparent container that makes the space for the backdrop photo. - new Container( - height: 175.0, - margin: const EdgeInsets.only(bottom: 118.0), - ), - new Positioned( - left: 10.0, - bottom: 0.0, - child: _buildPortraitPhoto(), - ), - new Positioned( - top: 186.0, - left: 132.0, - right: 16.0, - child: _buildEventInfo(), - ), - ], - ); - } - - Widget _buildPortraitPhoto() { - return new Padding( - padding: const EdgeInsets.all(6.0), - child: new EventPoster( - event: widget.event, - size: new Size(100.0, 150.0), - displayPlayButton: true, - ), - ); - } - - Widget _buildEventInfo() { - var content = []..addAll(_buildTitleAndLengthInMinutes()); - - if (widget.event.directors.isNotEmpty) { - content.add(new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: _buildDirectorInfo(), - )); - } - - return new Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: content, - ); - } - - List _buildTitleAndLengthInMinutes() { - var length = '${widget.event.lengthInMinutes} min'; - var genres = widget.event.genres.split(', ').take(4).join(', '); - - return [ - new Text( - widget.event.title, - style: new TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.w800, - ), - ), - new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: new Text( - '$length | $genres', - style: new TextStyle( - fontSize: 12.0, - fontWeight: FontWeight.w600, - ), - ), - ), - ]; - } - - Widget _buildDirectorInfo() { - return new Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Text( - 'Director:', - style: new TextStyle( - fontSize: 12.0, - color: Colors.black87, - fontWeight: FontWeight.w600, - ), - ), - new Expanded( - child: new Padding( - padding: const EdgeInsets.only(left: 4.0), - child: new Text( - widget.event.directors.first, - style: new TextStyle( - fontSize: 12.0, - color: Colors.black87, - ), - ), - ), - ), - ], - ); - } - - Widget _buildShowtimeInformation() { - if (widget.show != null) { - return new Padding( - padding: const EdgeInsets.only( - top: 24.0, - bottom: 8.0, - left: 16.0, - right: 16.0, - ), - child: new ShowtimeInformation(widget.show), - ); - } - - return null; - } - - Widget _buildSynopsis() { - if (widget.event.hasSynopsis) { - return new Padding( - padding: new EdgeInsets.only(top: widget.show == null ? 12.0 : 0.0), - child: new StorylineWidget(widget.event), - ); - } - - return null; - } - - Widget _buildActorScroller() => - widget.event.actors.isNotEmpty ? new ActorScroller(widget.event) : null; - - Widget _buildEventBackdrop() { - return new Positioned( - top: _scrollEffects.headerOffset, - child: new EventBackdropPhoto( - event: widget.event, - height: _scrollEffects.backdropHeight, - overlayBlur: _scrollEffects.backdropOverlayBlur, - blurOverlayOpacity: _scrollEffects.backdropOverlayOpacity, - ), - ); - } - - Widget _buildBackButton() { - return new Positioned( - top: MediaQuery.of(context).padding.top, - left: 4.0, - child: new IgnorePointer( - ignoring: _scrollEffects.backButtonOpacity == 0.0, - child: new Material( - type: MaterialType.circle, - color: Colors.transparent, - child: new BackButton( - color: Colors.white.withOpacity( - _scrollEffects.backButtonOpacity * 0.9, - ), - ), - ), - ), - ); - } - - Widget _buildStatusBarBackground() { - var statusBarColor = Theme.of(context).primaryColor; - - return new Container( - height: _scrollEffects.statusBarHeight, - color: statusBarColor, - ); - } - - @override - Widget build(BuildContext context) { - var content = [ - _buildHeader(context), - ]; - - addIfNonNull(_buildShowtimeInformation(), content); - addIfNonNull(_buildSynopsis(), content); - addIfNonNull(_buildActorScroller(), content); - - // Some padding for the bottom. - content.add(new Container(height: 32.0)); - - return new Scaffold( - backgroundColor: Colors.white, - body: new Stack( - children: [ - _buildEventBackdrop(), - new CustomScrollView( - controller: _scrollController, - slivers: [ - new SliverList(delegate: new SliverChildListDelegate(content)), - ], - ), - _buildBackButton(), - _buildStatusBarBackground(), - ], - ), - ); - } -} diff --git a/lib/ui/event_details/showtime_information.dart b/lib/ui/event_details/showtime_information.dart deleted file mode 100644 index d80d340f..00000000 --- a/lib/ui/event_details/showtime_information.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:intl/intl.dart'; -import 'package:meta/meta.dart'; -import 'package:url_launcher/url_launcher.dart'; - -@visibleForTesting -Function(String) launchTicketsUrl = (url) async { - if (await canLaunch(url)) { - await launch(url); - } -}; - -class ShowtimeInformation extends StatelessWidget { - static final Key ticketsButtonKey = new Key('ticketsButton'); - static final weekdayFormat = new DateFormat("E 'at' hh:mma"); - - ShowtimeInformation(this.show); - final Show show; - - Widget _buildTimeAndTheaterInformation() { - return new Padding( - padding: const EdgeInsets.only(left: 8.0), - child: new Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Text( - weekdayFormat.format(show.start), - style: new TextStyle( - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - new Text( - show.theaterAndAuditorium, - style: new TextStyle( - color: Colors.black54, - fontSize: 12.0, - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return new Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - new Expanded( - child: new Row( - children: [ - new Icon( - Icons.schedule, - color: Colors.black87, - ), - _buildTimeAndTheaterInformation(), - ], - ), - ), - new Padding( - padding: const EdgeInsets.only(left: 8.0), - child: new RaisedButton( - key: ticketsButtonKey, - onPressed: () => launchTicketsUrl(show.url), - color: Theme.of(context).accentColor, - textColor: Colors.white, - child: new Text('Tickets'), - ), - ), - ], - ); - } -} diff --git a/lib/ui/event_details/storyline_widget.dart b/lib/ui/event_details/storyline_widget.dart deleted file mode 100644 index 3b516ae3..00000000 --- a/lib/ui/event_details/storyline_widget.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/event.dart'; - -class StorylineWidget extends StatefulWidget { - StorylineWidget(this.event); - final Event event; - - @override - _StorylineWidgetState createState() => new _StorylineWidgetState(); -} - -class _StorylineWidgetState extends State { - bool _isExpandable; - bool _isExpanded = false; - - @override - void initState() { - super.initState(); - _isExpandable = widget.event.shortSynopsis != widget.event.synopsis; - } - - void _toggleExpandedState() { - setState(() { - _isExpanded = !_isExpanded; - }); - } - - Widget _buildCaption() { - var content = [ - new Text( - 'Storyline', - style: new TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.w700, - ), - ), - ]; - - if (_isExpandable) { - var action = _isExpanded ? 'collapse' : 'expand'; - - content.add(new Padding( - padding: const EdgeInsets.only(left: 4.0), - child: new Text( - '[touch to $action]', - style: new TextStyle( - fontSize: 12.0, - fontWeight: FontWeight.w600, - color: Colors.black54, - ), - ), - )); - } - - return new Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: content, - ); - } - - Widget _buildContent() { - return new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: new AnimatedCrossFade( - firstChild: new Text(widget.event.shortSynopsis), - secondChild: new Text(widget.event.synopsis), - crossFadeState: - _isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, - duration: kThemeAnimationDuration, - ), - ); - } - - @override - Widget build(BuildContext context) { - return new InkWell( - onTap: _isExpandable ? _toggleExpandedState : null, - child: new Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - child: new Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCaption(), - _buildContent(), - ], - ), - ), - ); - } -} diff --git a/lib/ui/events/event_grid.dart b/lib/ui/events/event_grid.dart deleted file mode 100644 index 3b1acaae..00000000 --- a/lib/ui/events/event_grid.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/ui/common/info_message_view.dart'; -import 'package:inkino/ui/event_details/event_details_page.dart'; -import 'package:inkino/ui/events/event_grid_item.dart'; -import 'package:meta/meta.dart'; - -class EventGrid extends StatelessWidget { - static final Key emptyViewKey = new Key('emptyView'); - static final Key contentKey = new Key('content'); - - EventGrid({ - @required this.events, - @required this.onReloadCallback, - }); - - final List events; - final VoidCallback onReloadCallback; - - void _openEventDetails(BuildContext context, Event event) { - Navigator.push( - context, - new MaterialPageRoute( - builder: (_) => new EventDetailsPage(event), - ), - ); - } - - Widget _buildContent(BuildContext context) { - var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait; - var crossAxisChildCount = isPortrait ? 2 : 4; - - return new Container( - key: contentKey, - color: const Color(0xFF222222), - child: new Scrollbar( - child: new GridView.builder( - gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisChildCount, - childAspectRatio: 2 / 3, - ), - itemCount: events.length, - itemBuilder: (BuildContext context, int index) { - var event = events[index]; - return new EventGridItem( - event: event, - onTapped: () => _openEventDetails(context, event), - ); - }, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (events.isEmpty) { - return new InfoMessageView( - key: emptyViewKey, - title: 'All empty!', - description: 'Didn\'t find any movies at\nall. ¯\\_(ツ)_/¯', - onActionButtonTapped: onReloadCallback, - ); - } - - return _buildContent(context); - } -} diff --git a/lib/ui/events/event_grid_item.dart b/lib/ui/events/event_grid_item.dart deleted file mode 100644 index e1b025ab..00000000 --- a/lib/ui/events/event_grid_item.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/ui/events/event_poster.dart'; -import 'package:meta/meta.dart'; - -class EventGridItem extends StatelessWidget { - EventGridItem({ - @required this.event, - @required this.onTapped, - }); - - final Event event; - final VoidCallback onTapped; - - BoxDecoration _buildGradientBackground() { - return new BoxDecoration( - gradient: new LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - stops: [0.0, 0.7, 0.7], - colors: [ - Colors.black, - Colors.transparent, - Colors.transparent, - ], - ), - ); - } - - Widget _buildTextualInfo() { - return new Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Text( - event.title, - style: new TextStyle( - fontWeight: FontWeight.w500, - fontSize: 16.0, - ), - ), - new Padding( - padding: const EdgeInsets.only(top: 4.0), - child: new Text( - event.genres, - style: new TextStyle( - fontSize: 12.0, - color: Colors.white70, - ), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return new DefaultTextStyle( - style: new TextStyle(color: Colors.white), - child: new Stack( - fit: StackFit.expand, - children: [ - new EventPoster(event: event), - new Container( - decoration: _buildGradientBackground(), - padding: const EdgeInsets.only( - bottom: 16.0, - left: 16.0, - right: 16.0, - ), - child: _buildTextualInfo(), - ), - new Material( - color: Colors.transparent, - child: new InkWell( - onTap: onTapped, - child: new Container(), - ), - ), - ], - ), - ); - } -} diff --git a/lib/ui/events/event_poster.dart b/lib/ui/events/event_poster.dart deleted file mode 100644 index 3fe0e248..00000000 --- a/lib/ui/events/event_poster.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/assets.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/utils/widget_utils.dart'; -import 'package:meta/meta.dart'; -import 'package:url_launcher/url_launcher.dart'; - -@visibleForTesting -Function(String) launchTrailerVideo = (url) async { - if (await canLaunch(url)) { - await launch(url); - } -}; - -class EventPoster extends StatelessWidget { - static final Key playButtonKey = new Key('playButton'); - - EventPoster({ - @required this.event, - this.size, - this.displayPlayButton = false, - }); - - final Event event; - final Size size; - final bool displayPlayButton; - - BoxDecoration _buildDecorations() { - return new BoxDecoration( - boxShadow: [ - new BoxShadow( - offset: const Offset(1.0, 1.0), - spreadRadius: 1.0, - blurRadius: 2.0, - color: Colors.black38, - ), - ], - gradient: new LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - const Color(0xFF222222), - const Color(0xFF424242), - ], - ), - ); - } - - Widget _buildPlayButton() { - if (displayPlayButton && event.youtubeTrailers.isNotEmpty) { - return new DecoratedBox( - decoration: new BoxDecoration( - shape: BoxShape.circle, - color: Colors.black38, - ), - child: new Material( - type: MaterialType.circle, - color: Colors.transparent, - child: new IconButton( - key: playButtonKey, - padding: EdgeInsets.zero, - icon: new Icon(Icons.play_circle_outline), - iconSize: 42.0, - color: Colors.white.withOpacity(0.8), - onPressed: () { - var url = event.youtubeTrailers.first; - launchTrailerVideo(url); - }, - ), - ), - ); - } - - return new Container(); - } - - @override - Widget build(BuildContext context) { - var content = [ - new Icon( - Icons.local_movies, - color: Colors.white24, - size: 72.0, - ), - new FadeInImage.assetNetwork( - placeholder: ImageAssets.transparentImage, - image: event.images.portraitMedium ?? 'http://example.com', - width: size?.width, - height: size?.height, - fadeInDuration: const Duration(milliseconds: 300), - fit: BoxFit.cover, - ), - ]; - - addIfNonNull(_buildPlayButton(), content); - - return new Container( - decoration: _buildDecorations(), - width: size?.width, - height: size?.height, - child: new Stack( - alignment: Alignment.center, - children: content, - ), - ); - } -} diff --git a/lib/ui/events/events_page_view_model.dart b/lib/ui/events/events_page_view_model.dart deleted file mode 100644 index 5e77761b..00000000 --- a/lib/ui/events/events_page_view_model.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/event/event_actions.dart'; -import 'package:inkino/redux/event/event_selectors.dart'; -import 'package:meta/meta.dart'; -import 'package:redux/redux.dart'; - -class EventsPageViewModel { - EventsPageViewModel({ - @required this.status, - @required this.events, - @required this.refreshEvents, - }); - - final LoadingStatus status; - final List events; - final Function refreshEvents; - - static EventsPageViewModel fromStore( - Store store, - EventListType type, - ) { - return new EventsPageViewModel( - status: store.state.eventState.loadingStatus, - events: eventsSelector(store.state, type), - refreshEvents: () => store.dispatch(new RefreshEventsAction()), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is EventsPageViewModel && - runtimeType == other.runtimeType && - status == other.status && - events == other.events && - refreshEvents == other.refreshEvents; - - @override - int get hashCode => - status.hashCode ^ - events.hashCode ^ - refreshEvents.hashCode; -} diff --git a/lib/ui/main_page.dart b/lib/ui/main_page.dart deleted file mode 100644 index b0103eff..00000000 --- a/lib/ui/main_page.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/app/app_actions.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/ui/events/events_page.dart'; -import 'package:inkino/ui/showtimes/showtimes_page.dart'; -import 'package:inkino/ui/theater_list/inkino_drawer_header.dart'; -import 'package:inkino/ui/theater_list/theater_list.dart'; - -class MainPage extends StatefulWidget { - @override - _MainPageState createState() => new _MainPageState(); -} - -class _MainPageState extends State - with SingleTickerProviderStateMixin { - static final GlobalKey scaffoldKey = - new GlobalKey(); - - TabController _controller; - TextEditingController _searchQuery; - bool _isSearching = false; - - @override - void initState() { - super.initState(); - _controller = new TabController(length: 3, vsync: this); - _searchQuery = new TextEditingController(); - } - - void _startSearch() { - ModalRoute - .of(context) - .addLocalHistoryEntry(new LocalHistoryEntry(onRemove: _stopSearching)); - - setState(() { - _isSearching = true; - }); - } - - void _stopSearching() { - _clearSearchQuery(); - - setState(() { - _isSearching = false; - }); - } - - void _clearSearchQuery() { - setState(() { - _searchQuery.clear(); - _updateSearchQuery(null); - }); - } - - Widget _buildTitle(BuildContext context) { - var horizontalTitleAlignment = - Platform.isIOS ? CrossAxisAlignment.center : CrossAxisAlignment.start; - - var subtitle = new StoreConnector( - converter: (store) => store.state.theaterState.currentTheater, - builder: (BuildContext context, Theater currentTheater) { - return new Text( - currentTheater?.name ?? '', - style: new TextStyle( - fontSize: 12.0, - color: Colors.white70, - ), - ); - }, - ); - - return new InkWell( - onTap: () => scaffoldKey.currentState.openDrawer(), - child: new Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: new Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: horizontalTitleAlignment, - children: [ - new Text('inKino'), - subtitle, - ], - ), - ), - ); - } - - Widget _buildSearchField() { - return new TextField( - controller: _searchQuery, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search movies & showtimes...', - border: InputBorder.none, - hintStyle: const TextStyle(color: Colors.white30), - ), - style: new TextStyle(color: Colors.white, fontSize: 16.0), - onChanged: _updateSearchQuery, - ); - } - - void _updateSearchQuery(String newQuery) { - var store = new StoreProvider.of(context).store; - store.dispatch(new SearchQueryChangedAction(newQuery)); - } - - List _buildActions() { - if (_isSearching) { - return [ - new IconButton( - icon: new Icon(Icons.clear), - onPressed: () { - if (_searchQuery == null || _searchQuery.text.isEmpty) { - // Stop searching. - Navigator.pop(context); - return; - } - - _clearSearchQuery(); - }, - ), - ]; - } - - return [ - new IconButton( - icon: new Icon(Icons.search), - onPressed: _startSearch, - ), - ]; - } - - @override - Widget build(BuildContext context) { - return new Scaffold( - key: scaffoldKey, - appBar: new AppBar( - leading: _isSearching ? new BackButton() : null, - title: _isSearching ? _buildSearchField() : _buildTitle(context), - actions: _buildActions(), - bottom: new TabBar( - controller: _controller, - isScrollable: true, - tabs: [ - new Tab(text: 'Now in theaters'), - new Tab(text: 'Showtimes'), - new Tab(text: 'Coming soon'), - ], - ), - ), - drawer: new Drawer( - child: new TheaterList( - header: new InKinoDrawerHeader(), - onTheaterTapped: () => Navigator.pop(context), - ), - ), - body: new TabBarView( - controller: _controller, - children: [ - new EventsPage(EventListType.nowInTheaters), - new ShowtimesPage(), - new EventsPage(EventListType.comingSoon), - ], - ), - ); - } -} diff --git a/lib/ui/showtimes/showtime_date_selector.dart b/lib/ui/showtimes/showtime_date_selector.dart deleted file mode 100644 index 2da87844..00000000 --- a/lib/ui/showtimes/showtime_date_selector.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/ui/showtimes/showtime_page_view_model.dart'; -import 'package:intl/intl.dart'; - -class ShowtimeDateSelector extends StatelessWidget { - ShowtimeDateSelector(this.viewModel); - final ShowtimesPageViewModel viewModel; - - Widget _buildDateItem(DateTime date) { - var color = date == viewModel.selectedDate - ? Colors.white - : Colors.white.withOpacity(0.4); - - var content = new Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - new Padding( - padding: const EdgeInsets.only(top: 10.0), - child: new Text( - new DateFormat('E').format(date), - style: new TextStyle( - fontSize: 12.0, - color: color, - ), - ), - ), - new Text( - date.day.toString(), - style: new TextStyle( - fontSize: 20.0, - fontWeight: FontWeight.w500, - color: color, - ), - ), - ], - ); - - return new Material( - color: Colors.transparent, - child: new InkWell( - onTap: () => viewModel.changeCurrentDate(date), - radius: 56.0, - child: new Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: content, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return new Container( - height: 56.0 + MediaQuery.of(context).padding.bottom, - color: const Color(0xFF222222), - child: new ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: viewModel.dates.length, - itemBuilder: (BuildContext context, int index) { - var date = viewModel.dates[index]; - return _buildDateItem(date); - }, - ), - ); - } -} diff --git a/lib/ui/showtimes/showtime_list.dart b/lib/ui/showtimes/showtime_list.dart deleted file mode 100644 index 8a6170bb..00000000 --- a/lib/ui/showtimes/showtime_list.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/ui/common/info_message_view.dart'; -import 'package:inkino/ui/showtimes/showtime_list_tile.dart'; - -class ShowtimeList extends StatelessWidget { - static final Key emptyViewKey = new Key('emptyView'); - static final Key contentKey = new Key('content'); - - ShowtimeList(this.shows); - final List shows; - - @override - Widget build(BuildContext context) { - if (shows.isEmpty) { - return new InfoMessageView( - key: emptyViewKey, - title: 'All empty!', - description: - 'Didn\'t find any movies\nabout to start for today. ¯\\_(ツ)_/¯', - ); - } - - return new Scrollbar( - key: contentKey, - child: new ListView.builder( - padding: const EdgeInsets.only(bottom: 8.0), - itemCount: shows.length, - itemBuilder: (BuildContext context, int index) { - var show = shows[index]; - var useAlternateBackground = index % 2 == 0; - - return new Column( - children: [ - new ShowtimeListTile(show, useAlternateBackground), - new Divider( - height: 1.0, - color: Colors.black.withOpacity(0.25), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/ui/showtimes/showtime_list_tile.dart b/lib/ui/showtimes/showtime_list_tile.dart deleted file mode 100644 index 9b488d79..00000000 --- a/lib/ui/showtimes/showtime_list_tile.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/redux/event/event_selectors.dart'; -import 'package:inkino/ui/event_details/event_details_page.dart'; -import 'package:intl/intl.dart'; - -class ShowtimeListTile extends StatelessWidget { - static final DateFormat hoursAndMins = new DateFormat('HH:mm'); - - ShowtimeListTile( - this.show, - this.useAlternateBackground, - ); - - final Show show; - final bool useAlternateBackground; - - void _navigateToEventDetails(BuildContext context) { - var store = new StoreProvider.of(context).store; - var event = eventForShowSelector(store.state, show); - - Navigator.push( - context, - new MaterialPageRoute( - builder: (_) => new EventDetailsPage(event, show: show), - ), - ); - } - - Widget _buildShowtimesInfo() { - return new Column( - children: [ - new Text( - hoursAndMins.format(show.start), - style: new TextStyle(fontSize: 20.0), - ), - new Text( - hoursAndMins.format(show.end), - style: new TextStyle( - fontSize: 14.0, - color: Colors.black54, - ), - ), - ], - ); - } - - Widget _buildDetailedInfo() { - var presentationMethodInfo = new Container( - decoration: new BoxDecoration( - color: Colors.black.withOpacity(0.1), - borderRadius: new BorderRadius.circular(4.0), - ), - margin: const EdgeInsets.only(top: 8.0), - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 2.0, - ), - child: new Text( - show.presentationMethod, - style: new TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12.0, - ), - ), - ); - - return new Expanded( - child: new Padding( - padding: const EdgeInsets.only(left: 20.0), - child: new Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - new Text( - show.title, - style: new TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14.0, - ), - ), - new Padding( - padding: const EdgeInsets.only(top: 4.0), - child: new Text(show.theaterAndAuditorium), - ), - presentationMethodInfo, - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - var backgroundColor = - useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white; - - return new Material( - color: backgroundColor, - child: new InkWell( - onTap: () => _navigateToEventDetails(context), - child: new Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), - child: new Row( - children: [ - _buildShowtimesInfo(), - _buildDetailedInfo(), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/theater_list/inkino_drawer_header.dart b/lib/ui/theater_list/inkino_drawer_header.dart deleted file mode 100644 index 52f12247..00000000 --- a/lib/ui/theater_list/inkino_drawer_header.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:inkino/assets.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class InKinoDrawerHeader extends StatefulWidget { - @override - _InKinoDrawerHeaderState createState() => new _InKinoDrawerHeaderState(); -} - -class _InKinoDrawerHeaderState extends State { - static const String flutterUrl = 'https://flutter.io/'; - static const String githubUrl = 'https://github.com/roughike/inKino'; - static final TextStyle linkStyle = new TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ); - - TapGestureRecognizer _flutterTapRecognizer; - TapGestureRecognizer _githubTapRecognizer; - - @override - void initState() { - super.initState(); - _flutterTapRecognizer = new TapGestureRecognizer() - ..onTap = () => _openUrl(flutterUrl); - _githubTapRecognizer = new TapGestureRecognizer() - ..onTap = () => _openUrl(githubUrl); - } - - @override - void dispose() { - _flutterTapRecognizer.dispose(); - _githubTapRecognizer.dispose(); - super.dispose(); - } - - void _openUrl(String url) async { - // Close the about dialog. - Navigator.pop(context); - - if (await canLaunch(url)) { - await launch(url); - } - } - - Widget _buildAppNameAndVersion(BuildContext context) { - var textTheme = Theme.of(context).textTheme; - - return new Padding( - padding: const EdgeInsets.all(16.0), - child: new Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - new Text( - 'inKino', - style: textTheme.display1.copyWith(color: Colors.white70), - ), - new Text( - 'v1.0.1', // TODO: figure out a way to get this dynamically - style: textTheme.body2.copyWith(color: Colors.white), - ), - ], - ), - ); - } - - Widget _buildAboutButton(BuildContext context) { - var content = new Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - new Icon( - Icons.info_outline, - color: Colors.white70, - size: 18.0, - ), - new Padding( - padding: const EdgeInsets.only(left: 8.0), - child: new Text( - 'About', - textAlign: TextAlign.end, - style: new TextStyle( - color: Colors.white70, - fontSize: 12.0, - ), - ), - ), - ], - ); - - return new Material( - color: Colors.transparent, - child: new InkWell( - onTap: () { - showDialog( - context: context, - child: _buildAboutDialog(context), - ); - }, - child: new Padding( - padding: const EdgeInsets.all(16.0), - child: content, - ), - ), - ); - } - - Widget _buildAboutDialog(BuildContext context) { - return new AlertDialog( - title: new Text('About inKino'), - content: new Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAboutText(), - _buildTMDBAttribution(), - ], - ), - actions: [ - new FlatButton( - onPressed: () { - Navigator.of(context).pop(); - }, - textColor: Theme.of(context).primaryColor, - child: new Text('Okay, got it!'), - ), - ], - ); - } - - Widget _buildAboutText() { - return new RichText( - text: new TextSpan( - text: 'inKino is the unofficial Finnkino client that ' - 'is minimalistic, fast, and delightful to use.\n\n', - style: new TextStyle(color: Colors.black87), - children: [ - new TextSpan(text: 'The app was developed with '), - new TextSpan( - text: 'Flutter', - recognizer: _flutterTapRecognizer, - style: linkStyle, - ), - new TextSpan( - text: ' and it\'s open source; check out the source ' - 'code yourself from ', - ), - new TextSpan( - text: 'the GitHub repo', - recognizer: _githubTapRecognizer, - style: linkStyle, - ), - new TextSpan(text: '.'), - ], - ), - ); - } - - Widget _buildTMDBAttribution() { - return new Padding( - padding: const EdgeInsets.only(top: 16.0), - child: new Row( - children: [ - new Padding( - padding: const EdgeInsets.only(top: 8.0), - child: new Image.asset( - ImageAssets.poweredByTMDBLogo, - width: 32.0, - ), - ), - new Expanded( - child: new Padding( - padding: const EdgeInsets.only(left: 12.0), - child: new Text( - 'This product uses the TMDb API but is not endorsed or certified by TMDb.', - style: new TextStyle(fontSize: 12.0), - ), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return new Container( - color: Theme.of(context).primaryColor, - constraints: new BoxConstraints.expand(height: 175.0), - child: new Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _buildAppNameAndVersion(context), - _buildAboutButton(context), - ], - ), - ); - } -} diff --git a/lib/ui/theater_list/theater_list.dart b/lib/ui/theater_list/theater_list.dart deleted file mode 100644 index 9ee1460d..00000000 --- a/lib/ui/theater_list/theater_list.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/ui/theater_list/theater_list_view_model.dart'; -import 'package:meta/meta.dart'; - -class TheaterList extends StatelessWidget { - TheaterList({ - @required this.header, - @required this.onTheaterTapped, - }); - - final Widget header; - final VoidCallback onTheaterTapped; - - @override - Widget build(BuildContext context) { - var statusBarHeight = MediaQuery.of(context).padding.vertical; - - return new Transform( - // FIXME: A hack for drawing behind the status bar, find a proper solution. - transform: new Matrix4.translationValues(0.0, -statusBarHeight, 0.0), - child: new StoreConnector( - distinct: true, - converter: (store) => TheaterListViewModel.fromStore(store), - builder: (BuildContext context, TheaterListViewModel viewModel) { - return new TheaterListContent( - header: header, - onTheaterTapped: onTheaterTapped, - viewModel: viewModel, - ); - }, - ), - ); - } -} - -class TheaterListContent extends StatelessWidget { - TheaterListContent({ - @required this.header, - @required this.onTheaterTapped, - @required this.viewModel, - }); - - final Widget header; - final VoidCallback onTheaterTapped; - final TheaterListViewModel viewModel; - - @override - Widget build(BuildContext context) { - return new ListView.builder( - itemCount: viewModel.theaters.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == 0) { - return header; - } - - var theater = viewModel.theaters[index - 1]; - var isSelected = viewModel.currentTheater.id == theater.id; - var backgroundColor = isSelected - ? const Color(0xFFEEEEEE) - : Theme.of(context).canvasColor; - - return new Material( - color: backgroundColor, - child: new ListTile( - onTap: () { - viewModel.changeCurrentTheater(theater); - onTheaterTapped(); - }, - selected: isSelected, - title: new Text(theater.name), - ), - ); - }, - ); - } -} diff --git a/.metadata b/mobile/.metadata similarity index 100% rename from .metadata rename to mobile/.metadata diff --git a/mobile/.travis.yml b/mobile/.travis.yml new file mode 100644 index 00000000..f9bae1fc --- /dev/null +++ b/mobile/.travis.yml @@ -0,0 +1,28 @@ +os: + - linux +sudo: false +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - libstdc++6 + - fonts-droid + +before_script: + - cd lib + - mv tmdb_config.dart.sample tmdb_config.dart + - cd .. + - git clone https://github.com/flutter/flutter.git -b beta --depth 1 + - ./flutter/bin/flutter doctor + - gem install coveralls-lcov + +script: + - ./flutter/bin/flutter test --coverage + +after_success: + - coveralls-lcov coverage/lcov.info + +cache: + directories: + - $HOME/.pub-cache \ No newline at end of file diff --git a/mobile/LICENSE b/mobile/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/mobile/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000..89aaddc4 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,43 @@ +# inKino - a showtime browser for Finnkino cinemas + +[![Build Status](https://travis-ci.org/roughike/inKino.svg?branch=development)](https://travis-ci.org/roughike/inKino) [![Coverage Status](https://coveralls.io/repos/github/roughike/inKino/badge.svg?branch=development)](https://coveralls.io/github/roughike/inKino?branch=development) + + + +## What is inKino? + +inKino is a minimal app for browsing movies and showtimes for [Finnkino](https://finnkino.fi/) cinemas. It's made with [Flutter](https://flutter.io/), uses [flutter_redux](https://github.com/brianegan/flutter_redux), and has an [extensive set of unit and widget tests](https://github.com/roughike/inKino/tree/development/test). It also has smooth transition animations and handles offline use cases gracefully. + +While I built inKino for my own needs, it is also intented to showcase good app structure and a clean, well-organized Flutter codebase. The app uses the [Finnkino XML API](https://finnkino.fi/xml) for fetching movies and showtimes, and the [TMDB API](https://www.themoviedb.org/documentation/api) for fetching the actor avatars. + +The source code is **100% Dart**, and everything resides in the [/lib](https://github.com/roughike/inKino/tree/development/lib) folder. + +
+Get it on Google Play Get it on the App Store +
+ +## Building the project + +### Renaming the TMDB configuration file + +If you try to build the project straight away, you'll get an error complaining that a `tmdb_config.dart` file is missing. To resolve that, run this on your terminal in the project root: + +```bash +cd lib && mv tmdb_config.dart.sample tmdb_config.dart && cd .. +``` + +**OR** + +If you don't trust in random bash scripts copied from the Internet, you can just rename the `tmdb_config.dart.sample` to `tmdb_config.dart` manually. + +### Building from source + +To build the project, ensure that you have a recent version of the Flutter SDK installed. Then either run `flutter run` in the project root or use your IDE of choice. To run the tests, run `flutter test` in the project root. + +## Contributing + +Contributions are welcome! However, if it's going to be a major change, please create an issue first. Before starting to work on something, please comment on a specific issue and say you'd like to work on it. + +## Thanks + +Special thanks to [Thibaud Colas](https://twitter.com/thibaud_colas), [Brian Egan](https://twitter.com/brianegan), [Alessandro Aime](https://twitter.com/aimealessandro) and [Juho Rautioaho](https://github.com/Jraut) for giving their extra pair of eyes for reviewing the source code. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 00000000..dabcfc13 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,7 @@ +analyzer: + language: + enableSuperMixins: true + +linter: + rules: + - prefer_const_constructors \ No newline at end of file diff --git a/android/.gitignore b/mobile/android/.gitignore similarity index 100% rename from android/.gitignore rename to mobile/android/.gitignore diff --git a/android/Gemfile b/mobile/android/Gemfile similarity index 100% rename from android/Gemfile rename to mobile/android/Gemfile diff --git a/android/app/build.gradle b/mobile/android/app/build.gradle similarity index 97% rename from android/app/build.gradle rename to mobile/android/app/build.gradle index 6ff481df..395b7939 100644 --- a/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -33,8 +33,8 @@ android { applicationId "com.roughike.inkino" minSdkVersion 16 targetSdkVersion 27 - versionCode 4 - versionName "1.0.1" + versionCode 6 + versionName "1.1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } diff --git a/mobile/android/app/release/app-release.apk b/mobile/android/app/release/app-release.apk new file mode 100644 index 00000000..b2205359 Binary files /dev/null and b/mobile/android/app/release/app-release.apk differ diff --git a/android/app/release/output.json b/mobile/android/app/release/output.json similarity index 66% rename from android/app/release/output.json rename to mobile/android/app/release/output.json index 8724bca1..c86317f8 100644 --- a/android/app/release/output.json +++ b/mobile/android/app/release/output.json @@ -1 +1 @@ -[{"outputType":{"type":"APK"},"apkInfo":{"type":"MAIN","splits":[],"versionCode":1},"path":"app-release.apk","properties":{"packageId":"com.roughike.inkino","split":"","minSdkVersion":"16"}}] \ No newline at end of file +[{"outputType":{"type":"APK"},"apkInfo":{"type":"MAIN","splits":[],"versionCode":5},"path":"app-release.apk","properties":{"packageId":"com.roughike.inkino","split":"","minSdkVersion":"16"}}] \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml similarity index 100% rename from android/app/src/main/AndroidManifest.xml rename to mobile/android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/java/com/roughike/inkino/MainActivity.java b/mobile/android/app/src/main/java/com/roughike/inkino/MainActivity.java similarity index 100% rename from android/app/src/main/java/com/roughike/inkino/MainActivity.java rename to mobile/android/app/src/main/java/com/roughike/inkino/MainActivity.java diff --git a/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable/launch_background.xml rename to mobile/android/app/src/main/res/drawable/launch_background.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml similarity index 100% rename from android/app/src/main/res/values/styles.xml rename to mobile/android/app/src/main/res/values/styles.xml diff --git a/android/build.gradle b/mobile/android/build.gradle similarity index 100% rename from android/build.gradle rename to mobile/android/build.gradle diff --git a/android/fastlane/Appfile b/mobile/android/fastlane/Appfile similarity index 100% rename from android/fastlane/Appfile rename to mobile/android/fastlane/Appfile diff --git a/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile similarity index 100% rename from android/fastlane/Fastfile rename to mobile/android/fastlane/Fastfile diff --git a/android/fastlane/metadata/android/en-US/changelogs/1.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/1.txt similarity index 100% rename from android/fastlane/metadata/android/en-US/changelogs/1.txt rename to mobile/android/fastlane/metadata/android/en-US/changelogs/1.txt diff --git a/android/fastlane/metadata/android/en-US/full_description.txt b/mobile/android/fastlane/metadata/android/en-US/full_description.txt similarity index 100% rename from android/fastlane/metadata/android/en-US/full_description.txt rename to mobile/android/fastlane/metadata/android/en-US/full_description.txt diff --git a/android/fastlane/metadata/android/en-US/images/icon.png b/mobile/android/fastlane/metadata/android/en-US/images/icon.png similarity index 100% rename from android/fastlane/metadata/android/en-US/images/icon.png rename to mobile/android/fastlane/metadata/android/en-US/images/icon.png diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png similarity index 100% rename from android/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png rename to mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png similarity index 100% rename from android/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png rename to mobile/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/mobile/android/fastlane/metadata/android/en-US/short_description.txt similarity index 100% rename from android/fastlane/metadata/android/en-US/short_description.txt rename to mobile/android/fastlane/metadata/android/en-US/short_description.txt diff --git a/android/fastlane/metadata/android/en-US/title.txt b/mobile/android/fastlane/metadata/android/en-US/title.txt similarity index 100% rename from android/fastlane/metadata/android/en-US/title.txt rename to mobile/android/fastlane/metadata/android/en-US/title.txt diff --git a/android/fastlane/metadata/android/en-US/video.txt b/mobile/android/fastlane/metadata/android/en-US/video.txt similarity index 100% rename from android/fastlane/metadata/android/en-US/video.txt rename to mobile/android/fastlane/metadata/android/en-US/video.txt diff --git a/android/gradle.properties b/mobile/android/gradle.properties similarity index 100% rename from android/gradle.properties rename to mobile/android/gradle.properties diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.properties rename to mobile/android/gradle/wrapper/gradle-wrapper.properties diff --git a/android/settings.gradle b/mobile/android/settings.gradle similarity index 100% rename from android/settings.gradle rename to mobile/android/settings.gradle diff --git a/assets/images/1x1_transparent.png b/mobile/assets/images/1x1_transparent.png similarity index 100% rename from assets/images/1x1_transparent.png rename to mobile/assets/images/1x1_transparent.png diff --git a/mobile/assets/images/background_image.jpg b/mobile/assets/images/background_image.jpg new file mode 100644 index 00000000..b170aff8 Binary files /dev/null and b/mobile/assets/images/background_image.jpg differ diff --git a/mobile/assets/images/logo.png b/mobile/assets/images/logo.png new file mode 100644 index 00000000..8cca4f19 Binary files /dev/null and b/mobile/assets/images/logo.png differ diff --git a/assets/images/powered_by_tmdb.png b/mobile/assets/images/powered_by_tmdb.png similarity index 100% rename from assets/images/powered_by_tmdb.png rename to mobile/assets/images/powered_by_tmdb.png diff --git a/ios/.gitignore b/mobile/ios/.gitignore similarity index 100% rename from ios/.gitignore rename to mobile/ios/.gitignore diff --git a/mobile/ios/.symlinks/flutter b/mobile/ios/.symlinks/flutter new file mode 120000 index 00000000..a7dc1905 --- /dev/null +++ b/mobile/ios/.symlinks/flutter @@ -0,0 +1 @@ +/Users/iirokrankka/flutter/bin/cache/artifacts/engine \ No newline at end of file diff --git a/mobile/ios/.symlinks/plugins/key_value_store_flutter b/mobile/ios/.symlinks/plugins/key_value_store_flutter new file mode 120000 index 00000000..b6edbc53 --- /dev/null +++ b/mobile/ios/.symlinks/plugins/key_value_store_flutter @@ -0,0 +1 @@ +/Users/iirokrankka/flutter/.pub-cache/hosted/pub.dartlang.org/key_value_store_flutter-1.0.0 \ No newline at end of file diff --git a/mobile/ios/.symlinks/plugins/path_provider b/mobile/ios/.symlinks/plugins/path_provider new file mode 120000 index 00000000..16d74d7d --- /dev/null +++ b/mobile/ios/.symlinks/plugins/path_provider @@ -0,0 +1 @@ +/Users/iirokrankka/flutter/.pub-cache/hosted/pub.dartlang.org/path_provider-0.4.1 \ No newline at end of file diff --git a/mobile/ios/.symlinks/plugins/shared_preferences b/mobile/ios/.symlinks/plugins/shared_preferences new file mode 120000 index 00000000..3cc00ade --- /dev/null +++ b/mobile/ios/.symlinks/plugins/shared_preferences @@ -0,0 +1 @@ +/Users/iirokrankka/flutter/.pub-cache/hosted/pub.dartlang.org/shared_preferences-0.4.3 \ No newline at end of file diff --git a/mobile/ios/.symlinks/plugins/url_launcher b/mobile/ios/.symlinks/plugins/url_launcher new file mode 120000 index 00000000..90bb8cd8 --- /dev/null +++ b/mobile/ios/.symlinks/plugins/url_launcher @@ -0,0 +1 @@ +/Users/iirokrankka/flutter/.pub-cache/hosted/pub.dartlang.org/url_launcher-3.0.3 \ No newline at end of file diff --git a/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from ios/Flutter/AppFrameworkInfo.plist rename to mobile/ios/Flutter/AppFrameworkInfo.plist diff --git a/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig similarity index 100% rename from ios/Flutter/Debug.xcconfig rename to mobile/ios/Flutter/Debug.xcconfig diff --git a/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig similarity index 100% rename from ios/Flutter/Release.xcconfig rename to mobile/ios/Flutter/Release.xcconfig diff --git a/mobile/ios/Gemfile b/mobile/ios/Gemfile new file mode 100644 index 00000000..7a118b49 --- /dev/null +++ b/mobile/ios/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/ios/Podfile b/mobile/ios/Podfile similarity index 83% rename from ios/Podfile rename to mobile/ios/Podfile index cdaa7b5e..7c6cb6f6 100644 --- a/ios/Podfile +++ b/mobile/ios/Podfile @@ -29,9 +29,8 @@ end target 'Runner' do # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock # referring to absolute paths on developers' machines. - system('rm -rf Pods/.symlinks') - system('mkdir -p Pods/.symlinks/flutter') - system('mkdir -p Pods/.symlinks/plugins') + system('rm -rf .symlinks') + system('mkdir -p .symlinks/plugins') # Flutter Pods generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig') @@ -40,16 +39,16 @@ target 'Runner' do end generated_xcode_build_settings.map { |p| if p[:name] == 'FLUTTER_FRAMEWORK_DIR' - symlink = File.join('Pods', '.symlinks', 'flutter', File.basename(p[:path])) - File.symlink(p[:path], symlink) - pod 'Flutter', :path => symlink + symlink = File.join('.symlinks', 'flutter') + File.symlink(File.dirname(p[:path]), symlink) + pod 'Flutter', :path => File.join(symlink, File.basename(p[:path])) end } # Plugin Pods plugin_pods = parse_KV_file('../.flutter-plugins') plugin_pods.map { |p| - symlink = File.join('Pods', '.symlinks', 'plugins', File.basename(p[:path])) + symlink = File.join('.symlinks', 'plugins', p[:name]) File.symlink(p[:path], symlink) pod p[:name], :path => File.join(symlink, 'ios') } diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock new file mode 100644 index 00000000..c6c164a8 --- /dev/null +++ b/mobile/ios/Podfile.lock @@ -0,0 +1,40 @@ +PODS: + - Flutter (1.0.0) + - key_value_store_flutter (0.0.1): + - Flutter + - path_provider (0.0.1): + - Flutter + - shared_preferences (0.0.1): + - Flutter + - url_launcher (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `.symlinks/flutter/ios`) + - key_value_store_flutter (from `.symlinks/plugins/key_value_store_flutter/ios`) + - path_provider (from `.symlinks/plugins/path_provider/ios`) + - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: ".symlinks/flutter/ios" + key_value_store_flutter: + :path: ".symlinks/plugins/key_value_store_flutter/ios" + path_provider: + :path: ".symlinks/plugins/path_provider/ios" + shared_preferences: + :path: ".symlinks/plugins/shared_preferences/ios" + url_launcher: + :path: ".symlinks/plugins/url_launcher/ios" + +SPEC CHECKSUMS: + Flutter: 9d0fac939486c9aba2809b7982dfdbb47a7b0296 + key_value_store_flutter: 5bf1aa677945ff2a2fc075b0e26f3bcaaecf049f + path_provider: 09407919825bfe3c2deae39453b7a5b44f467873 + shared_preferences: 5a1d487c427ee18fcd3ea1f2a131569481834b53 + url_launcher: 92b89c1029a0373879933c21642958c874539095 + +PODFILE CHECKSUM: 1e5af4103afd21ca5ead147d7b81d06f494f51a2 + +COCOAPODS: 1.5.3 diff --git a/mobile/ios/Runner.app.dSYM.zip b/mobile/ios/Runner.app.dSYM.zip new file mode 100644 index 00000000..3e2bc0a6 Binary files /dev/null and b/mobile/ios/Runner.app.dSYM.zip differ diff --git a/mobile/ios/Runner.ipa b/mobile/ios/Runner.ipa new file mode 100644 index 00000000..cb267fa3 Binary files /dev/null and b/mobile/ios/Runner.ipa differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj similarity index 96% rename from ios/Runner.xcodeproj/project.pbxproj rename to mobile/ios/Runner.xcodeproj/project.pbxproj index 21b9c542..4bd50354 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -160,8 +160,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 8F9BD0135D9E0D3E2D80022E /* [CP] Embed Pods Frameworks */, - 83DCF2B1EAD4FD9CDE4631F4 /* [CP] Copy Pods Resources */, + 1C6B0672594D338BE5F1EF58 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -223,49 +222,37 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; - }; - 83DCF2B1EAD4FD9CDE4631F4 /* [CP] Copy Pods Resources */ = { + 1C6B0672594D338BE5F1EF58 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${PODS_ROOT}/../.symlinks/flutter/ios/Flutter.framework", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 8F9BD0135D9E0D3E2D80022E /* [CP] Embed Pods Frameworks */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -287,13 +274,16 @@ files = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcworkspace/contents.xcworkspacedata rename to mobile/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..949b6789 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + BuildSystemType + Original + + diff --git a/ios/Runner/AppDelegate.h b/mobile/ios/Runner/AppDelegate.h similarity index 100% rename from ios/Runner/AppDelegate.h rename to mobile/ios/Runner/AppDelegate.h diff --git a/ios/Runner/AppDelegate.m b/mobile/ios/Runner/AppDelegate.m similarity index 100% rename from ios/Runner/AppDelegate.m rename to mobile/ios/Runner/AppDelegate.m diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-1024.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-1024.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-1024.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-1024.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@3x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-20@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@3x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-29@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@3x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-40@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@3x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-60@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-76@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-83.5@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-83.5@2x.png rename to mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ios_app_icon-83.5@2x.png diff --git a/ios/Runner/Assets.xcassets/Contents.json b/mobile/ios/Runner/Assets.xcassets/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/Contents.json rename to mobile/ios/Runner/Assets.xcassets/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from ios/Runner/Base.lproj/LaunchScreen.storyboard rename to mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from ios/Runner/Base.lproj/Main.storyboard rename to mobile/ios/Runner/Base.lproj/Main.storyboard diff --git a/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist similarity index 97% rename from ios/Runner/Info.plist rename to mobile/ios/Runner/Info.plist index fabacd0e..9eef9485 100644 --- a/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.1 + 1.1.0 CFBundleSignature ???? CFBundleVersion - 5 + 7 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/ios/Runner/main.m b/mobile/ios/Runner/main.m similarity index 100% rename from ios/Runner/main.m rename to mobile/ios/Runner/main.m diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml new file mode 100644 index 00000000..209d6a9d --- /dev/null +++ b/mobile/ios/fastlane/report.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/assets.dart b/mobile/lib/assets.dart similarity index 77% rename from lib/assets.dart rename to mobile/lib/assets.dart index 6eae7f0b..332507d4 100644 --- a/lib/assets.dart +++ b/mobile/lib/assets.dart @@ -1,8 +1,9 @@ class ImageAssets { static const String transparentImage = 'assets/images/1x1_transparent.png'; + static const String backgroundImage = 'assets/images/background_image.jpg'; static const String poweredByTMDBLogo = 'assets/images/powered_by_tmdb.png'; } class OtherAssets { static const String preloadedTheaters = 'assets/preloaded_data/theaters.xml'; -} \ No newline at end of file +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 00000000..0f03f2ee --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:http/http.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:inkino/ui/main_page.dart'; +import 'package:key_value_store_flutter/key_value_store_flutter.dart'; +import 'package:redux/redux.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future main() async { + final prefs = await SharedPreferences.getInstance(); + final keyValueStore = FlutterKeyValueStore(prefs); + final store = createStore(Client(), keyValueStore); + + FinnkinoApi.useFinnish = ui.window.locale.languageCode == 'fi'; + runApp(InKinoApp(store)); +} + +final supportedLocales = const [ + const Locale('en', 'US'), + const Locale('fi', 'FI'), +]; + +final localizationsDelegates = [ + const InKinoLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; + +class InKinoApp extends StatefulWidget { + InKinoApp(this.store); + final Store store; + + @override + _InKinoAppState createState() => _InKinoAppState(); +} + +class _InKinoAppState extends State { + @override + void initState() { + super.initState(); + widget.store.dispatch(InitAction()); + } + + @override + Widget build(BuildContext context) { + return StoreProvider( + store: widget.store, + child: MaterialApp( + title: 'inKino', + theme: ThemeData( + primaryColor: const Color(0xFF1C306D), + accentColor: const Color(0xFFFFAD32), + scaffoldBackgroundColor: Colors.transparent, + ), + home: const MainPage(), + supportedLocales: supportedLocales, + localizationsDelegates: localizationsDelegates, + ), + ); + } +} diff --git a/mobile/lib/message_provider.dart b/mobile/lib/message_provider.dart new file mode 100644 index 00000000..af97bb2d --- /dev/null +++ b/mobile/lib/message_provider.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class MessageProvider { + MessageProvider(this.messages); + final Messages messages; + + static Future load(Locale locale) { + final String name = + locale.countryCode.isEmpty ? locale.languageCode : locale.toString(); + final String localeName = Intl.canonicalizedLocale(name); + + return initializeMessages(localeName).then((_) { + Intl.defaultLocale = localeName; + return MessageProvider(Messages()); + }); + } + + static Messages of(BuildContext context) { + return Localizations.of(context, MessageProvider).messages; + } +} + +class InKinoLocalizationsDelegate + extends LocalizationsDelegate { + const InKinoLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => ['en', 'fi'].contains(locale.languageCode); + + @override + Future load(Locale locale) => MessageProvider.load(locale); + + @override + bool shouldReload(InKinoLocalizationsDelegate old) => false; +} diff --git a/mobile/lib/ui/common/info_message_view.dart b/mobile/lib/ui/common/info_message_view.dart new file mode 100644 index 00000000..216ecffc --- /dev/null +++ b/mobile/lib/ui/common/info_message_view.dart @@ -0,0 +1,101 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:meta/meta.dart'; + +class ErrorView extends InfoMessageView { + static const Key tryAgainButtonKey = Key('tryAgainButton'); + + const ErrorView({ + String title, + String description, + @required VoidCallback onRetry, + }) : super( + actionButtonKey: tryAgainButtonKey, + title: title, + description: description, + onActionButtonTapped: onRetry, + ); +} + +class InfoMessageView extends StatelessWidget { + const InfoMessageView({ + Key key, + @required this.title, + @required this.description, + this.actionButtonKey, + this.onActionButtonTapped, + }) : super(key: key); + + final String title; + final String description; + final Key actionButtonKey; + final VoidCallback onActionButtonTapped; + + List _buildContent(Messages messages) => [ + const CircleAvatar( + child: Icon( + Icons.info_outline, + color: Colors.white70, + size: 52.0, + ), + backgroundColor: Colors.white12, + radius: 42.0, + ), + const SizedBox(height: 16.0), + Text( + title ?? messages.oops, + style: const TextStyle(fontSize: 24.0, color: Colors.white), + ), + const SizedBox(height: 8.0), + Text( + description ?? messages.loadingMoviesError, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ]; + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + final content = _buildContent(messages); + + if (onActionButtonTapped != null) { + content.add(_ActionButton( + actionButtonKey, + onActionButtonTapped, + )); + } + + return SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: content, + ), + ), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + _ActionButton(Key key, this.onActionButtonTapped) : super(key: key); + final VoidCallback onActionButtonTapped; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: FlatButton( + onPressed: onActionButtonTapped, + child: Text( + MessageProvider.of(context).tryAgain, + style: const TextStyle(color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/ui/common/loading_view.dart b/mobile/lib/ui/common/loading_view.dart similarity index 52% rename from lib/ui/common/loading_view.dart rename to mobile/lib/ui/common/loading_view.dart index 093258ff..87dc44b2 100644 --- a/lib/ui/common/loading_view.dart +++ b/mobile/lib/ui/common/loading_view.dart @@ -1,13 +1,17 @@ +import 'dart:collection'; + +import 'package:core/core.dart'; import 'package:flutter/material.dart'; -import 'package:inkino/data/loading_status.dart'; import 'package:meta/meta.dart'; class LoadingView extends StatefulWidget { - static final Key loadingContentKey = new ValueKey('loading'); - static final Key errorContentKey = new ValueKey('error'); - static final Key successContentKey = new ValueKey('success'); + static const loadingContentKey = ValueKey('loading'); + static const errorContentKey = ValueKey('error'); + static const successContentKey = ValueKey('success'); + + static const successContentAnimationDuration = Duration(milliseconds: 400); - LoadingView({ + const LoadingView({ @required this.status, @required this.loadingContent, @required this.errorContent, @@ -20,7 +24,7 @@ class LoadingView extends StatefulWidget { final Widget successContent; @override - LoadingViewState createState() => new LoadingViewState(); + LoadingViewState createState() => LoadingViewState(); } class LoadingViewState extends State @@ -36,35 +40,40 @@ class LoadingViewState extends State Widget firstChild; Widget secondChild; + Queue> _animationStack = Queue(); + @override void initState() { super.initState(); - _loadingController = new AnimationController( - duration: new Duration(milliseconds: 350), + _loadingController = AnimationController( + duration: const Duration(milliseconds: 350), vsync: this, ); - _errorController = new AnimationController( - duration: new Duration(milliseconds: 350), + _errorController = AnimationController( + duration: const Duration(milliseconds: 350), vsync: this, ); - _successController = new AnimationController( - duration: new Duration(milliseconds: 400), + _successController = AnimationController( + duration: LoadingView.successContentAnimationDuration, vsync: this, ); switch (widget.status) { + case LoadingStatus.idle: case LoadingStatus.loading: - _loadingController.value = 1.0; + _animationStack.add(_loadingController.forward); break; case LoadingStatus.error: - _errorController.value = 1.0; + _animationStack.add(_errorController.forward); break; case LoadingStatus.success: - _successController.value = 1.0; + _animationStack.add(_successController.forward); break; } + + _playAnimations(); } @override @@ -83,51 +92,55 @@ class LoadingViewState extends State ValueGetter reverseAnimation; switch (oldWidget.status) { + case LoadingStatus.idle: case LoadingStatus.loading: - reverseAnimation = () => _loadingController.reverse(); + reverseAnimation = _loadingController.reverse; break; case LoadingStatus.error: - reverseAnimation = () => _errorController.reverse(); + reverseAnimation = _errorController.reverse; break; case LoadingStatus.success: - reverseAnimation = () => _successController.reverse(); + reverseAnimation = _successController.reverse; break; } - reverseAnimation().then((_) { - switch (widget.status) { - case LoadingStatus.loading: - _loadingController.forward(); - break; - case LoadingStatus.error: - _errorController.forward(); - break; - case LoadingStatus.success: - _successController.forward(); - break; - } - }); + _animationStack.add(reverseAnimation); + + switch (widget.status) { + case LoadingStatus.idle: + case LoadingStatus.loading: + _animationStack.add(_loadingController.forward); + break; + case LoadingStatus.error: + _animationStack.add(_errorController.forward); + break; + case LoadingStatus.success: + _animationStack.add(_successController.forward); + break; + } + + _playAnimations(); } } @override Widget build(BuildContext context) { - return new Stack( + return Stack( alignment: Alignment.center, - children: [ - new _TransitionAnimation( + children: [ + _TransitionAnimation( key: LoadingView.loadingContentKey, controller: _loadingController, child: widget.loadingContent, isVisible: widget.status == LoadingStatus.loading, ), - new _TransitionAnimation( + _TransitionAnimation( key: LoadingView.errorContentKey, controller: _errorController, child: widget.errorContent, isVisible: widget.status == LoadingStatus.error, ), - new _TransitionAnimation( + _TransitionAnimation( key: LoadingView.successContentKey, controller: _successController, child: widget.successContent, @@ -136,37 +149,45 @@ class LoadingViewState extends State ], ); } + + void _playAnimations() async { + await _animationStack.first(); + _animationStack.removeFirst(); + + if (_animationStack.isNotEmpty) { + _playAnimations(); + } + } } class _TransitionAnimation extends StatelessWidget { _TransitionAnimation({ - @required Key key, + @required this.key, @required this.controller, @required this.child, @required this.isVisible, - }) - : _opacity = new Tween(begin: 0.0, end: 1.0).animate( - new CurvedAnimation( + }) : _opacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( parent: controller, - curve: new Interval( + curve: const Interval( 0.000, 0.650, curve: Curves.ease, ), ), ), - _yTranslation = new Tween(begin: 40.0, end: 0.0).animate( - new CurvedAnimation( + _yTranslation = Tween(begin: 40.0, end: 0.0).animate( + CurvedAnimation( parent: controller, - curve: new Interval( + curve: const Interval( 0.000, 0.650, curve: Curves.ease, ), ), - ), - super(key: key); + ); + final Key key; final AnimationController controller; final Widget child; final bool isVisible; @@ -176,23 +197,26 @@ class _TransitionAnimation extends StatelessWidget { @override Widget build(BuildContext context) { - return new AnimatedBuilder( + return AnimatedBuilder( animation: controller, - builder: (BuildContext context, _) { - return new IgnorePointer( - ignoring: !isVisible, - child: new Transform( - transform: new Matrix4.translationValues( - 0.0, - _yTranslation.value, - 0.0, - ), - child: new Opacity( - opacity: _opacity.value, - child: child, - ), - ), - ); + builder: (_, __) { + return _opacity.value == 0.0 + ? const SizedBox.shrink() + : IgnorePointer( + key: key, + ignoring: !isVisible, + child: Transform( + transform: Matrix4.translationValues( + 0.0, + _yTranslation.value, + 0.0, + ), + child: Opacity( + opacity: _opacity.value, + child: child, + ), + ), + ); }, ); } diff --git a/lib/ui/common/platform_adaptive_progress_indicator.dart b/mobile/lib/ui/common/platform_adaptive_progress_indicator.dart similarity index 63% rename from lib/ui/common/platform_adaptive_progress_indicator.dart rename to mobile/lib/ui/common/platform_adaptive_progress_indicator.dart index 15b8c0dc..b33cff2c 100644 --- a/lib/ui/common/platform_adaptive_progress_indicator.dart +++ b/mobile/lib/ui/common/platform_adaptive_progress_indicator.dart @@ -4,10 +4,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class PlatformAdaptiveProgressIndicator extends StatelessWidget { + const PlatformAdaptiveProgressIndicator() : super(); + @override Widget build(BuildContext context) { return Platform.isIOS - ? new CupertinoActivityIndicator() - : new CircularProgressIndicator(); + ? const CupertinoActivityIndicator() + : const CircularProgressIndicator(); } } diff --git a/lib/utils/widget_utils.dart b/mobile/lib/ui/common/widget_utils.dart similarity index 100% rename from lib/utils/widget_utils.dart rename to mobile/lib/ui/common/widget_utils.dart diff --git a/mobile/lib/ui/event_details/actor_scroller.dart b/mobile/lib/ui/event_details/actor_scroller.dart new file mode 100644 index 00000000..ff008611 --- /dev/null +++ b/mobile/lib/ui/event_details/actor_scroller.dart @@ -0,0 +1,152 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/message_provider.dart'; + +class ActorScroller extends StatelessWidget { + const ActorScroller(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + return StoreConnector>( + onInit: (store) => store.dispatch(FetchActorAvatarsAction(event)), + converter: (store) => actorsForEventSelector(store.state, event), + builder: (_, actors) => ActorScrollerContent(actors), + ); + } +} + +class ActorScrollerContent extends StatelessWidget { + const ActorScrollerContent(this.actors); + final List actors; + + @override + Widget build(BuildContext context) { + return _ActorScrollerWrapper( + ListView.builder( + padding: const EdgeInsets.only(left: 16.0), + scrollDirection: Axis.horizontal, + itemCount: actors.length, + itemBuilder: (_, int index) { + final actor = actors[index]; + return _ActorListItem(actor); + }, + ), + ); + } +} + +class _ActorScrollerWrapper extends StatelessWidget { + _ActorScrollerWrapper(this.child); + final Widget child; + + @override + Widget build(BuildContext context) { + const decoration = BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + offset: Offset(0.0, -2.0), + spreadRadius: 2.0, + blurRadius: 30.0, + color: Colors.black12, + ), + ], + ); + + final title = Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + MessageProvider.of(context).cast, + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + ), + ), + ); + + return Container( + decoration: decoration, + padding: const EdgeInsets.only(top: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + title, + const SizedBox(height: 16.0), + SizedBox( + height: 110.0, + child: child, + ), + ], + ), + ); + } +} + +class _ActorListItem extends StatelessWidget { + _ActorListItem(this.actor); + final Actor actor; + + @override + Widget build(BuildContext context) { + return Container( + width: 90.0, + padding: const EdgeInsets.only(right: 16.0), + child: Column( + children: [ + _ActorAvatar(actor), + const SizedBox(height: 8.0), + Text( + actor.name, + style: const TextStyle(fontSize: 12.0), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _ActorAvatar extends StatelessWidget { + _ActorAvatar(this.actor); + final Actor actor; + + @override + Widget build(BuildContext context) { + final content = [ + const Icon( + Icons.person, + color: Colors.white, + size: 26.0, + ), + ]; + + if (actor.avatarUrl != null) { + content.add(ClipOval( + child: FadeInImage.assetNetwork( + placeholder: ImageAssets.transparentImage, + image: actor.avatarUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 250), + ), + )); + } + + return Container( + width: 56.0, + height: 56.0, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + shape: BoxShape.circle, + ), + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: content, + ), + ); + } +} diff --git a/mobile/lib/ui/event_details/event_backdrop_photo.dart b/mobile/lib/ui/event_details/event_backdrop_photo.dart new file mode 100644 index 00000000..a3f265d2 --- /dev/null +++ b/mobile/lib/ui/event_details/event_backdrop_photo.dart @@ -0,0 +1,160 @@ +import 'dart:ui' as ui; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/ui/event_details/event_details_scroll_effects.dart'; +import 'package:meta/meta.dart'; + +class EventBackdropPhoto extends StatelessWidget { + const EventBackdropPhoto({ + @required this.event, + @required this.scrollEffects, + }); + + final Event event; + final EventDetailsScrollEffects scrollEffects; + + @override + Widget build(BuildContext context) { + return ClipRect( + child: Stack( + children: [ + _BackdropPhoto(event, scrollEffects), + _BlurOverlay(scrollEffects), + _InsetShadow(), + ], + ), + ); + } +} + +class _BackdropPhoto extends StatelessWidget { + _BackdropPhoto(this.event, this.scrollEffects); + final Event event; + final EventDetailsScrollEffects scrollEffects; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + _PlaceholderBackground(scrollEffects.backdropHeight), + _BackdropImage(event, scrollEffects), + ], + ); + } +} + +class _PlaceholderBackground extends StatelessWidget { + _PlaceholderBackground(this.height); + final double height; + + @override + Widget build(BuildContext context) { + final decoration = const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color(0xFF222222), + Color(0xFF424242), + ], + ), + ); + + return Container( + width: MediaQuery.of(context).size.width, + height: height, + decoration: decoration, + child: const Center( + child: Icon( + Icons.theaters, + color: Colors.white30, + size: 96.0, + ), + ), + ); + } +} + +class _BackdropImage extends StatelessWidget { + _BackdropImage(this.event, this.scrollEffects); + final Event event; + final EventDetailsScrollEffects scrollEffects; + + String get photoUrl => + event.images.landscapeBig ?? event.images.landscapeSmall; + + @override + Widget build(BuildContext context) { + if (photoUrl == null) { + return const SizedBox.shrink(); + } + + final screenWidth = MediaQuery.of(context).size.width; + + return SizedBox( + width: screenWidth, + height: scrollEffects.backdropHeight, + child: FadeInImage.assetNetwork( + fadeInDuration: const Duration(milliseconds: 300), + placeholder: ImageAssets.transparentImage, + image: photoUrl, + width: screenWidth, + height: scrollEffects.backdropHeight, + fit: BoxFit.cover, + ), + ); + } +} + +class _BlurOverlay extends StatelessWidget { + _BlurOverlay(this.scrollEffects); + final EventDetailsScrollEffects scrollEffects; + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: scrollEffects.backdropOverlayBlur, + sigmaY: scrollEffects.backdropOverlayBlur, + ), + child: Container( + width: MediaQuery.of(context).size.width, + height: scrollEffects.backdropHeight, + decoration: BoxDecoration( + color: Colors.black.withOpacity( + scrollEffects.backdropOverlayOpacity * 0.4, + ), + ), + ), + ); + } +} + +class _InsetShadow extends StatelessWidget { + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Positioned( + bottom: -8.0, + child: DecoratedBox( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black38, + blurRadius: 5.0, + spreadRadius: 3.0, + ), + ], + ), + child: SizedBox( + width: screenWidth, + height: 10.0, + ), + ), + ); + } +} diff --git a/mobile/lib/ui/event_details/event_details_page.dart b/mobile/lib/ui/event_details/event_details_page.dart new file mode 100644 index 00000000..50ea86f5 --- /dev/null +++ b/mobile/lib/ui/event_details/event_details_page.dart @@ -0,0 +1,306 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:inkino/ui/event_details/actor_scroller.dart'; +import 'package:inkino/ui/event_details/event_backdrop_photo.dart'; +import 'package:inkino/ui/event_details/event_details_scroll_effects.dart'; +import 'package:inkino/ui/event_details/event_gallery_grid.dart'; +import 'package:inkino/ui/event_details/storyline_widget.dart'; +import 'package:inkino/ui/events/event_poster.dart'; +import 'package:inkino/ui/showtimes/showtime_list_tile.dart'; +import 'package:inkino/ui/common/widget_utils.dart'; + +class EventDetailsPage extends StatefulWidget { + EventDetailsPage( + this.event, { + this.show, + }); + + final Event event; + final Show show; + + @override + _EventDetailsPageState createState() => _EventDetailsPageState(); +} + +class _EventDetailsPageState extends State { + ScrollController _scrollController; + EventDetailsScrollEffects _scrollEffects; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _scrollController.addListener(_scrollListener); + _scrollEffects = EventDetailsScrollEffects(); + } + + @override + void dispose() { + _scrollController.removeListener(_scrollListener); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollListener() { + setState(() { + _scrollEffects.updateScrollOffset(context, _scrollController.offset); + }); + } + + Widget _buildShowtimeInformation() { + if (widget.show != null) { + return Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ShowtimeListTile( + widget.show, + opensEventDetails: false, + ), + ), + ); + } + + return null; + } + + Widget _buildSynopsis() { + if (widget.event.hasSynopsis) { + return Container( + color: Colors.white, + padding: EdgeInsets.only( + top: widget.show == null ? 12.0 : 0.0, + bottom: 16.0, + ), + child: StorylineWidget(widget.event), + ); + } + + return null; + } + + Widget _buildActorScroller() => + widget.event.actors.isNotEmpty ? ActorScroller(widget.event) : null; + + Widget _buildGallery() => widget.event.galleryImages.isNotEmpty + ? EventGalleryGrid(widget.event) + : Container(color: Colors.white, height: 500.0); + + Widget _buildEventBackdrop() { + return Positioned( + top: _scrollEffects.headerOffset, + child: EventBackdropPhoto( + event: widget.event, + scrollEffects: _scrollEffects, + ), + ); + } + + Widget _buildStatusBarBackground() { + final statusBarColor = Theme.of(context).primaryColor; + + return Container( + height: _scrollEffects.statusBarHeight, + color: statusBarColor, + ); + } + + @override + Widget build(BuildContext context) { + final content = [ + _Header(widget.event), + ]; + + addIfNonNull(_buildShowtimeInformation(), content); + addIfNonNull(_buildSynopsis(), content); + addIfNonNull(_buildActorScroller(), content); + addIfNonNull(_buildGallery(), content); + + // Some padding for the bottom. + content.add(const SizedBox(height: 32.0)); + + final backgroundImage = Positioned.fill( + child: Image.asset( + ImageAssets.backgroundImage, + fit: BoxFit.cover, + ), + ); + + final slivers = CustomScrollView( + controller: _scrollController, + slivers: [ + SliverList(delegate: SliverChildListDelegate(content)), + ], + ); + + return Scaffold( + backgroundColor: const Color(0xFFF0F0F0), + body: Stack( + children: [ + backgroundImage, + _buildEventBackdrop(), + slivers, + _BackButton(_scrollEffects), + _buildStatusBarBackground(), + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + _Header(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + final moviePoster = Padding( + padding: const EdgeInsets.all(6.0), + child: EventPoster( + event: event, + size: const Size(125.0, 187.5), + displayPlayButton: true, + ), + ); + + return Stack( + children: [ + // Transparent container that makes the space for the backdrop photo. + Container( + height: 225.0, + margin: const EdgeInsets.only(bottom: 132.0), + ), + // Makes for the white background in poster and event information. + Positioned( + bottom: 0.0, + left: 0.0, + right: 0.0, + child: Container( + color: Colors.white, + height: 132.0, + ), + ), + Positioned( + left: 10.0, + bottom: 0.0, + child: moviePoster, + ), + Positioned( + top: 238.0, + left: 156.0, + right: 16.0, + child: _EventInfo(event), + ), + ], + ); + } +} + +class _BackButton extends StatelessWidget { + _BackButton(this.scrollEffects); + final EventDetailsScrollEffects scrollEffects; + + @override + Widget build(BuildContext context) { + return Positioned( + top: MediaQuery.of(context).padding.top, + left: 4.0, + child: IgnorePointer( + ignoring: scrollEffects.backButtonOpacity == 0.0, + child: Material( + type: MaterialType.circle, + color: Colors.transparent, + child: BackButton( + color: Colors.white.withOpacity( + scrollEffects.backButtonOpacity * 0.9, + ), + ), + ), + ), + ); + } +} + + +class _EventInfo extends StatelessWidget { + _EventInfo(this.event); + final Event event; + + List _buildTitleAndLengthInMinutes() { + final length = '${event.lengthInMinutes} min'; + final genres = event.genres.split(', ').take(4).join(', '); + + return [ + Text( + event.title, + style: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8.0), + Text( + '$length | $genres', + style: const TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w600, + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + final content = []..addAll( + _buildTitleAndLengthInMinutes(), + ); + + if (event.directors.isNotEmpty) { + content.add(Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _DirectorInfo(event.director), + )); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: content, + ); + } +} + +class _DirectorInfo extends StatelessWidget { + _DirectorInfo(this.director); + final String director; + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${messages.director}:', + style: const TextStyle( + fontSize: 12.0, + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4.0), + Expanded( + child: Text( + director, + style: const TextStyle( + fontSize: 12.0, + color: Colors.black87, + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/event_details/event_details_scroll_effects.dart b/mobile/lib/ui/event_details/event_details_scroll_effects.dart similarity index 88% rename from lib/ui/event_details/event_details_scroll_effects.dart rename to mobile/lib/ui/event_details/event_details_scroll_effects.dart index ac706f1f..93fa66fb 100644 --- a/lib/ui/event_details/event_details_scroll_effects.dart +++ b/mobile/lib/ui/event_details/event_details_scroll_effects.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; // FIXME: This is ugly, but works. Look into doing this with Animations and // Tweens instead. class EventDetailsScrollEffects { - static const double kHeaderHeight = 175.0; + static const double kHeaderHeight = 225.0; EventDetailsScrollEffects() { updateScrollOffset(null, 0.0); @@ -27,10 +27,11 @@ class EventDetailsScrollEffects { } void _recalculateValues(BuildContext context) { - var unconstrainedBackdropHeight = kHeaderHeight + (-_scrollOffset); + final unconstrainedBackdropHeight = kHeaderHeight + (-_scrollOffset); backdropHeight = max(80.0, unconstrainedBackdropHeight); - backdropOverlayOpacity = _calculateOverlayOpacity(unconstrainedBackdropHeight); + backdropOverlayOpacity = + _calculateOverlayOpacity(unconstrainedBackdropHeight); backdropOverlayBlur = _calculateBackdropBlur(); headerOffset = _calculateHeaderOffset(unconstrainedBackdropHeight); backButtonOpacity = _calculateBackButtonOpacity(); @@ -51,7 +52,7 @@ class EventDetailsScrollEffects { } double _calculateBackdropBlur() { - var backdropOverscrollBlur = max(0.0, min(20.0, -_scrollOffset / 6)); + final backdropOverscrollBlur = max(0.0, min(20.0, -_scrollOffset / 6)); return backdropOverscrollBlur == 0.0 ? backdropOverlayOpacity * 5.0 diff --git a/mobile/lib/ui/event_details/event_gallery_grid.dart b/mobile/lib/ui/event_details/event_gallery_grid.dart new file mode 100644 index 00000000..aeebf2ac --- /dev/null +++ b/mobile/lib/ui/event_details/event_gallery_grid.dart @@ -0,0 +1,94 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/message_provider.dart'; + +class EventGalleryGrid extends StatelessWidget { + EventGalleryGrid(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Title(), + _Grid(event), + ], + ), + ); + } +} + +class _Title extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + MessageProvider.of(context).gallery, + style: const TextStyle( + fontSize: 18.0, + color: Colors.white, + ), + ), + ); + } +} + +class _Grid extends StatelessWidget { + _Grid(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + return GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.25 / 1, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + ), + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 8.0, + ), + children: event.galleryImages.map((image) { + return _GalleryImage(image.location); + }).toList(), + ); + } +} + +class _GalleryImage extends StatelessWidget { + _GalleryImage(this.url); + final String url; + + @override + Widget build(BuildContext context) { + final decoration = const BoxDecoration( + boxShadow: [ + BoxShadow( + spreadRadius: 2.0, + blurRadius: 5.0, + offset: Offset(2.0, 2.0), + color: Colors.black38, + ), + ], + ); + + return Container( + margin: const EdgeInsets.all(8.0), + decoration: decoration, + child: FadeInImage.assetNetwork( + placeholder: ImageAssets.transparentImage, + image: url, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/mobile/lib/ui/event_details/storyline_widget.dart b/mobile/lib/ui/event_details/storyline_widget.dart new file mode 100644 index 00000000..ea01571b --- /dev/null +++ b/mobile/lib/ui/event_details/storyline_widget.dart @@ -0,0 +1,100 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/message_provider.dart'; + +class StorylineWidget extends StatefulWidget { + StorylineWidget(this.event); + final Event event; + + @override + _StorylineWidgetState createState() => _StorylineWidgetState(); +} + +class _StorylineWidgetState extends State { + bool _isExpandable; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _isExpandable = widget.event.shortSynopsis != widget.event.synopsis; + } + + void _toggleExpandedState() { + setState(() { + _isExpanded = !_isExpanded; + }); + } + + @override + Widget build(BuildContext context) { + final content = AnimatedCrossFade( + firstChild: Text(widget.event.shortSynopsis), + secondChild: Text(widget.event.synopsis), + crossFadeState: + _isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: kThemeAnimationDuration, + ); + + return InkWell( + onTap: _isExpandable ? _toggleExpandedState : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Title(_isExpandable, _isExpanded), + const SizedBox(height: 8.0), + content, + ], + ), + ), + ); + } +} + +class _Title extends StatelessWidget { + _Title(this.expandable, this.expanded); + final bool expandable; + final bool expanded; + + Widget _buildExpandCollapsePrompt(Messages messages) { + const captionStyle = TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w600, + color: Colors.black54, + ); + + return Text( + expanded ? messages.collapseStoryline : messages.expandStoryline, + style: captionStyle, + ); + } + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + final content = [ + Text( + messages.storyline, + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + ), + ), + ]; + + if (expandable) { + content.add(Padding( + padding: const EdgeInsets.only(left: 4.0), + child: _buildExpandCollapsePrompt(messages), + )); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: content, + ); + } +} diff --git a/mobile/lib/ui/events/event_grid.dart b/mobile/lib/ui/events/event_grid.dart new file mode 100644 index 00000000..faa5d738 --- /dev/null +++ b/mobile/lib/ui/events/event_grid.dart @@ -0,0 +1,85 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:inkino/ui/common/info_message_view.dart'; +import 'package:inkino/ui/event_details/event_details_page.dart'; +import 'package:inkino/ui/events/event_grid_item.dart'; +import 'package:meta/meta.dart'; + +class EventGrid extends StatelessWidget { + static const emptyViewKey = const Key('emptyView'); + static const contentKey = const Key('content'); + + EventGrid({ + @required this.listType, + @required this.events, + @required this.onReloadCallback, + }); + + final EventListType listType; + final List events; + final VoidCallback onReloadCallback; + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + + if (events.isEmpty) { + return InfoMessageView( + key: emptyViewKey, + title: messages.allEmpty, + description: messages.noMovies, + onActionButtonTapped: onReloadCallback, + ); + } + + return _Content(events, listType); + } +} + +class _Content extends StatelessWidget { + _Content(this.events, this.listType); + final List events; + final EventListType listType; + + void _openEventDetails(BuildContext context, Event event) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EventDetailsPage(event), + ), + ); + } + + Widget _buildItem(BuildContext context, int index) { + final event = events[index]; + + return EventGridItem( + event: event, + onTapped: () => _openEventDetails(context, event), + showReleaseDateInformation: listType == EventListType.comingSoon, + ); + } + + @override + Widget build(BuildContext context) { + final isPortrait = + MediaQuery.of(context).orientation == Orientation.portrait; + final crossAxisChildCount = isPortrait ? 2 : 4; + + return Container( + key: EventGrid.contentKey, + child: Scrollbar( + child: GridView.builder( + padding: const EdgeInsets.only(bottom: 50.0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisChildCount, + childAspectRatio: 2 / 3, + ), + itemCount: events.length, + itemBuilder: _buildItem, + ), + ), + ); + } +} diff --git a/mobile/lib/ui/events/event_grid_item.dart b/mobile/lib/ui/events/event_grid_item.dart new file mode 100644 index 00000000..a72c0fed --- /dev/null +++ b/mobile/lib/ui/events/event_grid_item.dart @@ -0,0 +1,104 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/ui/events/event_poster.dart'; +import 'package:inkino/ui/events/event_release_date_information.dart'; +import 'package:meta/meta.dart'; + +class EventGridItem extends StatelessWidget { + EventGridItem({ + @required this.event, + @required this.onTapped, + @required this.showReleaseDateInformation, + }); + + final Event event; + final VoidCallback onTapped; + final bool showReleaseDateInformation; + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Stack( + fit: StackFit.expand, + children: [ + EventPoster(event: event), + _TextualInfo(event), + Positioned( + top: 10.0, + child: Visibility( + visible: showReleaseDateInformation, + child: EventReleaseDateInformation(event), + ), + ), + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTapped, + child: Container(), + ), + ), + ], + ), + ); + } +} + +class _TextualInfo extends StatelessWidget { + _TextualInfo(this.event); + final Event event; + + BoxDecoration _buildGradientBackground() { + return const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + stops: [0.0, 0.7, 0.7], + colors: [ + Colors.black, + Colors.transparent, + Colors.transparent, + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: _buildGradientBackground(), + padding: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0), + child: _TextualInfoContent(event), + ); + } +} + +class _TextualInfoContent extends StatelessWidget { + _TextualInfoContent(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16.0, + ), + ), + const SizedBox(height: 4.0), + Text( + event.genres, + style: const TextStyle( + fontSize: 12.0, + color: Colors.white70, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/ui/events/event_poster.dart b/mobile/lib/ui/events/event_poster.dart new file mode 100644 index 00000000..da2f11b9 --- /dev/null +++ b/mobile/lib/ui/events/event_poster.dart @@ -0,0 +1,118 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/ui/common/widget_utils.dart'; +import 'package:meta/meta.dart'; +import 'package:url_launcher/url_launcher.dart'; + +@visibleForTesting +Function(String) launchTrailerVideo = (url) async { + if (await canLaunch(url)) { + await launch(url); + } +}; + +class EventPoster extends StatelessWidget { + static const Key playButtonKey = const Key('playButton'); + + EventPoster({ + @required this.event, + this.size, + this.displayPlayButton = false, + }); + + final Event event; + final Size size; + final bool displayPlayButton; + + Widget _buildPlayButton() => + displayPlayButton && event.youtubeTrailers.isNotEmpty + ? _PlayButton(event) + : null; + + Widget _buildPosterImage() => event.images.portraitMedium != null + ? FadeInImage.assetNetwork( + placeholder: ImageAssets.transparentImage, + image: event.images.portraitMedium, + width: size?.width, + height: size?.height, + fadeInDuration: const Duration(milliseconds: 300), + fit: BoxFit.cover, + ) + : null; + + @override + Widget build(BuildContext context) { + final content = [ + const Icon( + Icons.local_movies, + color: Colors.white24, + size: 72.0, + ), + ]; + + addIfNonNull(_buildPosterImage(), content); + addIfNonNull(_buildPlayButton(), content); + + return Container( + decoration: _buildDecorations(), + width: size?.width, + height: size?.height, + child: Stack( + alignment: Alignment.center, + children: content, + ), + ); + } +} + +class _PlayButton extends StatelessWidget { + _PlayButton(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black38, + ), + child: Material( + type: MaterialType.circle, + color: Colors.transparent, + child: IconButton( + key: EventPoster.playButtonKey, + padding: EdgeInsets.zero, + icon: const Icon(Icons.play_circle_outline), + iconSize: 42.0, + color: Colors.white.withOpacity(0.8), + onPressed: () { + final url = event.youtubeTrailers.first; + launchTrailerVideo(url); + }, + ), + ), + ); + } +} + +BoxDecoration _buildDecorations() { + return const BoxDecoration( + boxShadow: [ + BoxShadow( + offset: Offset(1.0, 1.0), + spreadRadius: 1.0, + blurRadius: 2.0, + color: Colors.black38, + ), + ], + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color(0xFF222222), + Color(0xFF424242), + ], + ), + ); +} diff --git a/mobile/lib/ui/events/event_release_date_information.dart b/mobile/lib/ui/events/event_release_date_information.dart new file mode 100644 index 00000000..14298dcc --- /dev/null +++ b/mobile/lib/ui/events/event_release_date_information.dart @@ -0,0 +1,48 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:intl/intl.dart'; + +class EventReleaseDateInformation extends StatelessWidget { + static final _releaseDateFormat = DateFormat('dd.MM.yyyy'); + + EventReleaseDateInformation(this.event); + final Event event; + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + + return Container( + color: Colors.black87, + padding: const EdgeInsets.only( + top: 5.0, + right: 20.0, + bottom: 5.0, + left: 10.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + messages.releaseDate, + style: TextStyle( + color: Theme.of(context).accentColor, + fontSize: 12.0, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2.0), + Text( + _releaseDateFormat.format(event.releaseDate), + style: const TextStyle( + color: const Color(0xFFFEFEFE), + fontWeight: FontWeight.w300, + fontSize: 16.0, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/events/events_page.dart b/mobile/lib/ui/events/events_page.dart similarity index 59% rename from lib/ui/events/events_page.dart rename to mobile/lib/ui/events/events_page.dart index 7ca2e959..263ea251 100644 --- a/lib/ui/events/events_page.dart +++ b/mobile/lib/ui/events/events_page.dart @@ -1,12 +1,12 @@ +import 'package:core/core.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; -import 'package:inkino/data/models/event.dart'; +import 'package:inkino/message_provider.dart'; import 'package:inkino/ui/common/info_message_view.dart'; import 'package:inkino/ui/common/loading_view.dart'; import 'package:inkino/ui/common/platform_adaptive_progress_indicator.dart'; import 'package:inkino/ui/events/event_grid.dart'; -import 'package:inkino/ui/events/events_page_view_model.dart'; class EventsPage extends StatelessWidget { EventsPage(this.listType); @@ -14,28 +14,32 @@ class EventsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return new StoreConnector( + return StoreConnector( distinct: true, + onInit: (store) => store.dispatch(FetchComingSoonEventsIfNotLoadedAction()), converter: (store) => EventsPageViewModel.fromStore(store, listType), - builder: (_, viewModel) => new EventsPageContent(viewModel), + builder: (_, viewModel) => EventsPageContent(viewModel, listType), ); } } class EventsPageContent extends StatelessWidget { - EventsPageContent(this.viewModel); + EventsPageContent(this.viewModel, this.listType); final EventsPageViewModel viewModel; + final EventListType listType; @override Widget build(BuildContext context) { - return new LoadingView( + final messages = MessageProvider.of(context); + return LoadingView( status: viewModel.status, - loadingContent: new PlatformAdaptiveProgressIndicator(), - errorContent: new ErrorView( - description: 'Error loading events.', + loadingContent: const PlatformAdaptiveProgressIndicator(), + errorContent: ErrorView( + description: messages.errorLoadingEvents, onRetry: viewModel.refreshEvents, ), - successContent: new EventGrid( + successContent: EventGrid( + listType: listType, events: viewModel.events, onReloadCallback: viewModel.refreshEvents, ), diff --git a/mobile/lib/ui/inkino_app_bar.dart b/mobile/lib/ui/inkino_app_bar.dart new file mode 100644 index 00000000..31b8fabd --- /dev/null +++ b/mobile/lib/ui/inkino_app_bar.dart @@ -0,0 +1,200 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:inkino/ui/theater_list/theater_selector_popup.dart'; + +class InkinoAppBar extends StatefulWidget { + @override + _InkinoAppBarState createState() => _InkinoAppBarState(); +} + +class _InkinoAppBarState extends State + with SingleTickerProviderStateMixin { + TextEditingController _searchQuery; + LocalHistoryEntry _searchEntry; + + bool _isSearching = false; + bool _theatersOpen = false; + + @override + void initState() { + super.initState(); + + _searchEntry = LocalHistoryEntry(onRemove: _stopSearching); + _searchQuery = TextEditingController(); + } + + @override + void dispose() { + super.dispose(); + } + + void _toggleTheaters() async { + if (Navigator.canPop(context)) { + Navigator.pop(context); + _setTheatersOpenFlag(false); + } else { + _setTheatersOpenFlag(true); + + await Navigator.push(context, TheaterSelectorPopup()); + _setTheatersOpenFlag(false); + } + } + + void _setTheatersOpenFlag(bool open) { + setState(() { + _theatersOpen = open; + }); + } + + List _buildActions() { + if (_isSearching) { + return [ + IconButton( + color: Colors.white70, + icon: const Icon(Icons.clear), + onPressed: () { + if (_searchQuery == null || _searchQuery.text.isEmpty) { + // Stop searching. + Navigator.pop(context); + return; + } + + _clearSearchQuery(); + }, + ), + ]; + } + + return [ + _TheaterIconButton(_theatersOpen, _toggleTheaters), + IconButton( + color: Colors.white70, + icon: const Icon(Icons.search), + onPressed: _startSearch, + ), + ]; + } + + Widget _buildSearchField() { + return TextField( + controller: _searchQuery, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search movies & showtimes...', + border: InputBorder.none, + hintStyle: const TextStyle(color: Colors.white30), + ), + style: const TextStyle(color: Colors.white, fontSize: 16.0), + onChanged: _updateSearchQuery, + ); + } + + void _updateSearchQuery(String newQuery) { + final store = StoreProvider.of(context); + store.dispatch(SearchQueryChangedAction(newQuery)); + } + + void _startSearch() { + ModalRoute.of(context).addLocalHistoryEntry(_searchEntry); + + setState(() { + _isSearching = true; + }); + } + + void _stopSearching() { + _clearSearchQuery(); + + setState(() { + _isSearching = false; + }); + } + + void _clearSearchQuery() { + setState(() { + _searchQuery.clear(); + _updateSearchQuery(null); + }); + } + + @override + Widget build(BuildContext context) { + return AppBar( + automaticallyImplyLeading: false, + centerTitle: false, + leading: _isSearching ? const BackButton() : null, + title: _isSearching ? _buildSearchField() : _Title(_toggleTheaters), + actions: _buildActions(), + ); + } +} + +class _Title extends StatelessWidget { + _Title(this.toggleTheaters); + final VoidCallback toggleTheaters; + + @override + Widget build(BuildContext context) { + final subtitle = StoreConnector( + converter: (store) => store.state.theaterState.currentTheater, + builder: (BuildContext context, Theater currentTheater) { + return Text( + currentTheater?.name ?? '', + style: const TextStyle( + fontSize: 12.0, + color: Colors.white70, + ), + ); + }, + ); + + final title = Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('inKino'), + subtitle, + ], + ); + + return GestureDetector( + onTap: toggleTheaters, + child: Row( + children: [ + Image.asset('assets/images/logo.png', width: 28.0, height: 28.0), + const SizedBox(width: 8.0), + title, + ], + ), + ); + } +} + +class _TheaterIconButton extends StatelessWidget { + _TheaterIconButton(this.theatersOpen, this.toggleTheaters); + final bool theatersOpen; + final VoidCallback toggleTheaters; + + @override + Widget build(BuildContext context) { + final backgroundColor = + theatersOpen ? const Color(0xFF152451) : Colors.transparent; + + return AnimatedContainer( + duration: const Duration(milliseconds: 175), + color: backgroundColor, + child: GestureDetector( + child: const Padding( + padding: EdgeInsets.all(16.0), + child: Icon( + Icons.place, + color: Colors.white70, + size: 24.0, + ), + ), + onTap: toggleTheaters, + ), + ); + } +} diff --git a/mobile/lib/ui/inkino_bottom_bar.dart b/mobile/lib/ui/inkino_bottom_bar.dart new file mode 100644 index 00000000..10f16483 --- /dev/null +++ b/mobile/lib/ui/inkino_bottom_bar.dart @@ -0,0 +1,29 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class InkinoBottomBar extends StatelessWidget { + InkinoBottomBar({ + @required this.currentIndex, + @required this.onTap, + @required this.items, + }); + + final int currentIndex; + final ValueChanged onTap; + final List items; + + @override + Widget build(BuildContext context) { + /// Yes - I'm using CupertinoTabBar on both Android and iOS. It looks dope. + /// I'm not a designer and only God can judge me. (╯°□°)╯︵ ┻━┻ + return CupertinoTabBar( + backgroundColor: Colors.black54, + inactiveColor: Colors.white54, + activeColor: Colors.white, + iconSize: 24.0, + currentIndex: currentIndex, + onTap: onTap, + items: items, + ); + } +} diff --git a/mobile/lib/ui/main_page.dart b/mobile/lib/ui/main_page.dart new file mode 100644 index 00000000..65bcb24e --- /dev/null +++ b/mobile/lib/ui/main_page.dart @@ -0,0 +1,126 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/assets.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:inkino/ui/events/events_page.dart'; +import 'package:inkino/ui/inkino_app_bar.dart'; +import 'package:inkino/ui/inkino_bottom_bar.dart'; +import 'package:inkino/ui/showtimes/showtimes_page.dart'; + +class MainPage extends StatefulWidget { + const MainPage(); + + @override + _MainPageState createState() => _MainPageState(); +} + +class _MainPageState extends State + with SingleTickerProviderStateMixin { + TabController _tabController; + int _selectedTab = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + Widget _buildTabContent() { + return Positioned.fill( + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: [ + EventsPage(EventListType.nowInTheaters), + const ShowtimesPage(), + EventsPage(EventListType.comingSoon), + ], + ), + ); + } + + void _tabSelected(int newIndex) { + setState(() { + _selectedTab = newIndex; + _tabController.index = newIndex; + }); + } + + @override + Widget build(BuildContext context) { + final backgroundImage = Image.asset( + ImageAssets.backgroundImage, + fit: BoxFit.cover, + ); + + final content = Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: InkinoAppBar(), + ), + body: Stack( + children: [ + _buildTabContent(), + _BottomTabs( + selectedTab: _selectedTab, + onTap: _tabSelected, + ), + ], + ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + backgroundImage, + content, + ], + ); + } +} + +class _BottomTabs extends StatelessWidget { + _BottomTabs({ + @required this.selectedTab, + @required this.onTap, + }); + + final int selectedTab; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + + return Align( + alignment: Alignment.bottomCenter, + child: InkinoBottomBar( + currentIndex: selectedTab, + onTap: onTap, + items: [ + BottomNavigationBarItem( + title: Text(messages.nowInTheaters), + icon: const Icon(Icons.theaters), + backgroundColor: Theme.of(context).primaryColor, + ), + BottomNavigationBarItem( + title: Text(messages.showtimes), + icon: const Icon(Icons.schedule), + backgroundColor: Theme.of(context).primaryColor, + ), + BottomNavigationBarItem( + title: Text(messages.comingSoon), + icon: const Icon(Icons.whatshot), + backgroundColor: Theme.of(context).primaryColor, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/showtimes/showtime_date_selector.dart b/mobile/lib/ui/showtimes/showtime_date_selector.dart new file mode 100644 index 00000000..584b8f2c --- /dev/null +++ b/mobile/lib/ui/showtimes/showtime_date_selector.dart @@ -0,0 +1,105 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class ShowtimeDateSelector extends StatelessWidget { + ShowtimeDateSelector(this.viewModel); + final ShowtimesPageViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFF0F1633), + boxShadow: [ + BoxShadow( + color: Colors.black45, + blurRadius: 5.0, + spreadRadius: 2.0, + ), + ], + ), + height: 56.0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: viewModel.dates.map((date) { + return _DateSelectorItem(date, viewModel); + }).toList(), + ), + ); + } +} + +class _DateSelectorItem extends StatelessWidget { + _DateSelectorItem( + this.date, + this.viewModel, + ); + + final DateTime date; + final ShowtimesPageViewModel viewModel; + + @override + Widget build(BuildContext context) { + final isSelected = date == viewModel.selectedDate; + final backgroundColor = + isSelected ? const Color(0xFFF9C243) : const Color(0xFF0F1633); + + return AnimatedContainer( + duration: const Duration(milliseconds: 100), + color: backgroundColor, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => viewModel.changeCurrentDate(date), + radius: 56.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _ItemContent(date, isSelected), + ), + ), + ), + ); + } +} + +class _ItemContent extends StatelessWidget { + static final dateFormat = DateFormat('E'); + + _ItemContent(this.date, this.isSelected); + final DateTime date; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final dayColor = + isSelected ? const Color(0xFF0F1633) : const Color(0xFF717DAD); + final dateColor = isSelected ? const Color(0xFF0F1633) : Colors.white; + final dateWeight = isSelected ? FontWeight.w500 : FontWeight.w300; + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(height: 10.0), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 100), + style: TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w500, + color: dayColor, + ), + child: Text(dateFormat.format(date)), + ), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 100), + style: TextStyle( + fontSize: 20.0, + fontWeight: dateWeight, + color: dateColor, + ), + child: Text(date.day.toString()), + ), + ], + ); + } +} diff --git a/mobile/lib/ui/showtimes/showtime_list.dart b/mobile/lib/ui/showtimes/showtime_list.dart new file mode 100644 index 00000000..4ed37d35 --- /dev/null +++ b/mobile/lib/ui/showtimes/showtime_list.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:inkino/message_provider.dart'; +import 'package:inkino/ui/common/info_message_view.dart'; +import 'package:inkino/ui/common/loading_view.dart'; +import 'package:inkino/ui/showtimes/showtime_list_tile.dart'; + +class ShowtimeList extends StatefulWidget { + static const Key emptyViewKey = Key('emptyView'); + static const Key contentKey = Key('content'); + + ShowtimeList(this.status, this.shows); + final LoadingStatus status; + final List shows; + + @override + _ShowtimeListState createState() => _ShowtimeListState(); +} + +class _ShowtimeListState extends State { + List _shows = []; + bool _showEmptyView = false; + + @override + void initState() { + super.initState(); + _shows = widget.shows; + _showEmptyView = _shows.isEmpty && widget.status == LoadingStatus.success; + } + + @override + void didUpdateWidget(ShowtimeList oldWidget) { + super.didUpdateWidget(oldWidget); + + /// We do this dance here since we want to "freeze" the content until + /// the [LoadingView] hides us completely. + if (oldWidget.status != widget.status) { + /// Loading status changed and shows got updated; update them after the + /// animation finishes. + if (widget.status == LoadingStatus.success) { + Timer( + LoadingView.successContentAnimationDuration, + () => _shows = widget.shows, + ); + } + } else if (widget.status == LoadingStatus.success) { + /// Loading status didn't change, so update the shows instantly. + _shows = widget.shows; + } + + _showEmptyView = + widget.shows.isEmpty && widget.status == LoadingStatus.success; + } + + @override + Widget build(BuildContext context) { + final messages = MessageProvider.of(context); + + if (_showEmptyView) { + return InfoMessageView( + key: ShowtimeList.emptyViewKey, + title: messages.allEmpty, + description: messages.noMoviesForToday, + ); + } + + return Scrollbar( + key: ShowtimeList.contentKey, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 50.0), + itemCount: _shows.length, + itemBuilder: (BuildContext context, int index) { + final show = _shows[index]; + return ShowtimeListTile(show); + }, + ), + ); + } +} diff --git a/mobile/lib/ui/showtimes/showtime_list_tile.dart b/mobile/lib/ui/showtimes/showtime_list_tile.dart new file mode 100644 index 00000000..471e4106 --- /dev/null +++ b/mobile/lib/ui/showtimes/showtime_list_tile.dart @@ -0,0 +1,177 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:inkino/ui/event_details/event_details_page.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +@visibleForTesting +Function(String) launchTicketsUrl = (url) async { + if (await canLaunch(url)) { + await launch(url); + } +}; + +class ShowtimeListTile extends StatelessWidget { + ShowtimeListTile( + this.show, { + this.opensEventDetails = true, + }) : ticketsButtonKey = Key('${show.id}-tickets'); + + final Show show; + final bool opensEventDetails; + final Key ticketsButtonKey; + + void _navigateToEventDetails(BuildContext context) { + final store = StoreProvider.of(context); + final event = eventForShowSelector(store.state, show); + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EventDetailsPage(event, show: show), + ), + ); + } + + @override + Widget build(BuildContext context) { + final onTap = + opensEventDetails ? () => _navigateToEventDetails(context) : null; + + final ticketsButton = IconButton( + key: ticketsButtonKey, + color: Theme.of(context).accentColor, + icon: const Icon(Icons.theaters), + onPressed: () => launchTicketsUrl(show.url), + ); + + final content = Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), + child: Row( + children: [ + _ShowtimesInfo(show), + _DetailedInfo(show), + ticketsButton, + ], + ), + ); + + return Padding( + padding: const EdgeInsets.only(top: 1.0), + child: Material( + color: const Color(0xE00D1736), + child: InkWell( + onTap: onTap, + child: content, + ), + ), + ); + } +} + +class _ShowtimesInfo extends StatelessWidget { + static final hoursAndMins = DateFormat('HH:mm'); + + _ShowtimesInfo(this.show); + final Show show; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + hoursAndMins.format(show.start), + style: const TextStyle( + fontSize: 18.0, + color: const Color(0xFFFEFEFE), + ), + ), + const SizedBox(height: 4.0), + Text( + hoursAndMins.format(show.end), + style: const TextStyle( + fontSize: 14.0, + color: const Color(0xFF717DAD), + ), + ), + ], + ); + } +} + +class _DetailedInfo extends StatelessWidget { + _DetailedInfo(this.show); + final Show show; + + @override + Widget build(BuildContext context) { + final decoration = const BoxDecoration( + border: Border( + left: BorderSide( + color: Color(0xFF717DAD), + ), + ), + ); + + final content = [ + Text( + show.title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14.0, + color: const Color(0xFFFEFEFE), + ), + ), + const SizedBox(height: 4.0), + Text( + show.theaterAndAuditorium, + style: const TextStyle( + color: Color(0xFF717DAD), + ), + ), + _PresentationMethodChip(show), + ]; + + return Expanded( + child: Container( + decoration: decoration, + margin: const EdgeInsets.only(left: 12.0), + padding: const EdgeInsets.only(left: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: content, + ), + ), + ); + } +} + +class _PresentationMethodChip extends StatelessWidget { + _PresentationMethodChip(this.show); + final Show show; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF21316B), + borderRadius: BorderRadius.circular(10.0), + ), + margin: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + child: Text( + show.presentationMethod, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12.0, + color: Color(0xFFFEFEFE), + ), + ), + ); + } +} diff --git a/lib/ui/showtimes/showtimes_page.dart b/mobile/lib/ui/showtimes/showtimes_page.dart similarity index 58% rename from lib/ui/showtimes/showtimes_page.dart rename to mobile/lib/ui/showtimes/showtimes_page.dart index e35ca896..a33fbfba 100644 --- a/lib/ui/showtimes/showtimes_page.dart +++ b/mobile/lib/ui/showtimes/showtimes_page.dart @@ -1,3 +1,4 @@ +import 'package:core/core.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; @@ -6,15 +7,17 @@ import 'package:inkino/ui/common/loading_view.dart'; import 'package:inkino/ui/common/platform_adaptive_progress_indicator.dart'; import 'package:inkino/ui/showtimes/showtime_date_selector.dart'; import 'package:inkino/ui/showtimes/showtime_list.dart'; -import 'package:inkino/ui/showtimes/showtime_page_view_model.dart'; class ShowtimesPage extends StatelessWidget { + const ShowtimesPage(); + @override Widget build(BuildContext context) { - return new StoreConnector( + return StoreConnector( distinct: true, + onInit: (store) => store.dispatch(FetchShowsIfNotLoadedAction()), converter: (store) => ShowtimesPageViewModel.fromStore(store), - builder: (_, viewModel) => new ShowtimesPageContent(viewModel), + builder: (_, viewModel) => ShowtimesPageContent(viewModel), ); } } @@ -25,17 +28,18 @@ class ShowtimesPageContent extends StatelessWidget { @override Widget build(BuildContext context) { - return new Column( - children: [ - new Expanded( - child: new LoadingView( + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ShowtimeDateSelector(viewModel), + Expanded( + child: LoadingView( status: viewModel.status, - loadingContent: new PlatformAdaptiveProgressIndicator(), - errorContent: new ErrorView(onRetry: viewModel.refreshShowtimes), - successContent: new ShowtimeList(viewModel.shows), + loadingContent: const PlatformAdaptiveProgressIndicator(), + errorContent: ErrorView(onRetry: viewModel.refreshShowtimes), + successContent: ShowtimeList(viewModel.status, viewModel.shows), ), ), - new ShowtimeDateSelector(viewModel), ], ); } diff --git a/mobile/lib/ui/theater_list/theater_list.dart b/mobile/lib/ui/theater_list/theater_list.dart new file mode 100644 index 00000000..e1dcd6f5 --- /dev/null +++ b/mobile/lib/ui/theater_list/theater_list.dart @@ -0,0 +1,69 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:meta/meta.dart'; + +class TheaterList extends StatelessWidget { + TheaterList({ + @required this.onTheaterTapped, + }); + + final VoidCallback onTheaterTapped; + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: (store) => TheaterListViewModel.fromStore(store), + builder: (BuildContext context, TheaterListViewModel viewModel) { + return TheaterListContent( + onTheaterTapped: onTheaterTapped, + viewModel: viewModel, + ); + }, + ); + } +} + +class TheaterListContent extends StatelessWidget { + TheaterListContent({ + @required this.onTheaterTapped, + @required this.viewModel, + }); + + final VoidCallback onTheaterTapped; + final TheaterListViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Scrollbar( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: viewModel.theaters.length, + itemBuilder: (BuildContext context, int index) { + final theater = viewModel.theaters[index]; + final isSelected = viewModel.currentTheater.id == theater.id; + final backgroundColor = + isSelected ? Colors.black54 : Colors.transparent; + final foregroundColor = + isSelected ? Colors.white : Colors.white.withOpacity(0.56); + + return Material( + color: backgroundColor, + child: ListTile( + onTap: () { + viewModel.changeCurrentTheater(theater); + onTheaterTapped(); + }, + selected: isSelected, + title: Text( + theater.name, + style: TextStyle(color: foregroundColor), + ), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/ui/theater_list/theater_selector_popup.dart b/mobile/lib/ui/theater_list/theater_selector_popup.dart new file mode 100644 index 00000000..bdad7dd8 --- /dev/null +++ b/mobile/lib/ui/theater_list/theater_selector_popup.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:inkino/ui/theater_list/theater_list.dart'; + +class TheaterSelectorPopup extends PopupRoute { + final opacityTween = Tween(begin: 0.0, end: 1.0); + final positionTween = Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ); + + @override + Color get barrierColor => null; + + @override + bool get barrierDismissible => true; + + @override + String get barrierLabel => null; + + @override + Duration get transitionDuration => kThemeAnimationDuration; + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) { + final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight; + final curve = CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.decelerate, + ); + + return Padding( + padding: EdgeInsets.only(top: topPadding), + child: ClipRect( + child: FadeTransition( + opacity: opacityTween.animate(curve), + child: SlideTransition( + position: positionTween.animate(curve), + child: child, + ), + ), + ), + ); + } + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + return Container( + color: const Color(0xFF152451), + child: TheaterList(onTheaterTapped: () => Navigator.pop(context)), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 00000000..cd108d9b --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,30 @@ +name: inkino +description: A new Flutter application. + +dependencies: + core: + path: ../core + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + xml: ^3.0.0 + flutter_redux: ^0.5.0 + intl: ^0.15.2 + url_launcher: ^3.0.0 + path_provider: ^0.4.0 + key_value_store_flutter: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^3.0.0 + image_test_utils: ^1.0.0 + +flutter: + uses-material-design: true + assets: + - assets/images/1x1_transparent.png + - assets/images/powered_by_tmdb.png + - assets/images/background_image.jpg + - assets/images/logo.png \ No newline at end of file diff --git a/mobile/screenshots/app_store.png b/mobile/screenshots/app_store.png new file mode 100644 index 00000000..b088ae46 Binary files /dev/null and b/mobile/screenshots/app_store.png differ diff --git a/screenshots/event_details.png b/mobile/screenshots/event_details.png similarity index 100% rename from screenshots/event_details.png rename to mobile/screenshots/event_details.png diff --git a/mobile/screenshots/google_play.png b/mobile/screenshots/google_play.png new file mode 100644 index 00000000..f9ae0527 Binary files /dev/null and b/mobile/screenshots/google_play.png differ diff --git a/screenshots/now_in_theaters.png b/mobile/screenshots/now_in_theaters.png similarity index 100% rename from screenshots/now_in_theaters.png rename to mobile/screenshots/now_in_theaters.png diff --git a/screenshots/showtimes.png b/mobile/screenshots/showtimes.png similarity index 100% rename from screenshots/showtimes.png rename to mobile/screenshots/showtimes.png diff --git a/test/mocks.dart b/mobile/test/mocks.dart similarity index 55% rename from test/mocks.dart rename to mobile/test/mocks.dart index 557a67e9..75bbc8f0 100644 --- a/test/mocks.dart +++ b/mobile/test/mocks.dart @@ -1,16 +1,11 @@ import 'dart:io'; import 'package:flutter/services.dart'; -import 'package:inkino/data/networking/finnkino_api.dart'; -import 'package:inkino/redux/app/app_state.dart'; import 'package:mockito/mockito.dart'; -import 'package:redux/redux.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MockFile extends Mock implements File {} -class MockFinnkinoApi extends Mock implements FinnkinoApi {} class MockAssetBundle extends Mock implements AssetBundle {} -class MockStore extends Mock implements Store {} -class MockPreferences extends Mock implements SharedPreferences {} \ No newline at end of file +class MockPreferences extends Mock implements SharedPreferences {} diff --git a/mobile/test/ui/common/loading_view_test.dart b/mobile/test/ui/common/loading_view_test.dart new file mode 100644 index 00000000..78bf6503 --- /dev/null +++ b/mobile/test/ui/common/loading_view_test.dart @@ -0,0 +1,51 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkino/ui/common/loading_view.dart'; + +void main() { + group('LoadingView', () { + const loading = Key('test-loading'); + const error = Key('test-error'); + const success = Key('test-success'); + + Future _pumpWithLoadingStatus( + WidgetTester tester, LoadingStatus status) async { + return tester.pumpWidget(MaterialApp( + home: LoadingView( + status: status, + loadingContent: Container(key: loading), + errorContent: Container(key: error), + successContent: Container(key: success), + ), + )); + } + + testWidgets('loading', (WidgetTester tester) async { + await _pumpWithLoadingStatus(tester, LoadingStatus.loading); + await tester.pumpAndSettle(); + + expect(find.byKey(loading), findsOneWidget); + expect(find.byKey(error), findsNothing); + expect(find.byKey(success), findsNothing); + }); + + testWidgets('error', (WidgetTester tester) async { + await _pumpWithLoadingStatus(tester, LoadingStatus.error); + await tester.pumpAndSettle(); + + expect(find.byKey(loading), findsNothing); + expect(find.byKey(error), findsOneWidget); + expect(find.byKey(success), findsNothing); + }); + + testWidgets('success', (WidgetTester tester) async { + await _pumpWithLoadingStatus(tester, LoadingStatus.success); + await tester.pumpAndSettle(); + + expect(find.byKey(loading), findsNothing); + expect(find.byKey(error), findsNothing); + expect(find.byKey(success), findsOneWidget); + }); + }); +} diff --git a/test/ui/event_details/event_details_page_test.dart b/mobile/test/ui/event_details/event_details_page_test.dart similarity index 53% rename from test/ui/event_details/event_details_page_test.dart rename to mobile/test/ui/event_details/event_details_page_test.dart index 43e66295..465ff8ec 100644 --- a/test/ui/event_details/event_details_page_test.dart +++ b/mobile/test/ui/event_details/event_details_page_test.dart @@ -1,28 +1,36 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/show.dart'; +import 'package:image_test_utils/image_test_utils.dart'; +import 'package:inkino/main.dart'; import 'package:inkino/ui/event_details/event_details_page.dart' as eventDetails; -import 'package:inkino/ui/event_details/showtime_information.dart'; -import 'package:inkino/ui/event_details/showtime_information.dart' - as showtimeInfo; import 'package:inkino/ui/events/event_poster.dart'; import 'package:inkino/ui/events/event_poster.dart' as eventPoster; +import 'package:inkino/ui/showtimes/showtime_list_tile.dart' + as showtimeListTile; +import 'package:inkino/ui/showtimes/showtime_list_tile.dart'; import 'package:meta/meta.dart'; -import '../../test_utils.dart'; - void main() { group('EventDetailsPage', () { + final showId = '123'; + final ticketsButtonKey = Key('${showId}-tickets'); + final show = Show( + id: showId, + title: 'Test show', + start: DateTime(2018), + end: DateTime(2018), + presentationMethod: '2D', + theaterAndAuditorium: 'Test theater', + url: 'https://finnkino.fi/test-tickets-url', + ); + String lastLaunchedTicketsUrl; String lastLaunchedTrailerUrl; setUp(() { - mockAllImageResponses(); - - showtimeInfo.launchTicketsUrl = (url) => lastLaunchedTicketsUrl = url; + showtimeListTile.launchTicketsUrl = (url) => lastLaunchedTicketsUrl = url; eventPoster.launchTrailerVideo = (url) => lastLaunchedTrailerUrl = url; }); @@ -31,33 +39,39 @@ void main() { lastLaunchedTrailerUrl = null; }); - Future _buildEventDetailsPage( + Future _buildEventDetailsPage( WidgetTester tester, { @required List trailers, @required Show show, }) { - return tester.pumpWidget(new MaterialApp( - home: new eventDetails.EventDetailsPage( - new Event( - id: '1', - title: 'Test Title', - genres: 'Test Genres', - directors: [], - actors: [], - images: new EventImageData.empty(), - youtubeTrailers: trailers, + return provideMockedNetworkImages(() async { + return tester.pumpWidget(MaterialApp( + supportedLocales: supportedLocales, + localizationsDelegates: localizationsDelegates, + home: eventDetails.EventDetailsPage( + Event( + id: '1', + title: 'Test Title', + genres: 'Test Genres', + directors: [], + actors: [], + images: EventImageData.empty(), + galleryImages: [], + youtubeTrailers: trailers, + ), + show: show, ), - show: show, - ), - )); + )); + }); } testWidgets( 'when navigated to with a null show, should not display showtime information widget in the UI', (WidgetTester tester) async { - await _buildEventDetailsPage(tester, trailers: [], show: null); + await _buildEventDetailsPage(tester, trailers: [], show: null); + await tester.pump(); - expect(find.byType(ShowtimeInformation), findsNothing); + expect(find.byType(ShowtimeListTile), findsNothing); }, ); @@ -66,14 +80,13 @@ void main() { (WidgetTester tester) async { await _buildEventDetailsPage( tester, - trailers: [], - show: new Show( - start: new DateTime(2018), - theaterAndAuditorium: 'Test theater', - ), + trailers: [], + show: show, ); - var showtimeInfoFinder = find.byType(ShowtimeInformation); + await tester.pump(); + + final showtimeInfoFinder = find.byType(ShowtimeListTile); expect(showtimeInfoFinder, findsOneWidget); expect( find.descendant( @@ -90,15 +103,14 @@ void main() { (WidgetTester tester) async { await _buildEventDetailsPage( tester, - trailers: [], - show: new Show( - start: new DateTime(2018), - theaterAndAuditorium: 'Test theater', - url: 'https://finnkino.fi/test-tickets-url', - ), + trailers: [], + show: show, ); - await tester.tap(find.byKey(ShowtimeInformation.ticketsButtonKey)); + await tester.pump(); + await tester.tap(find.byKey(ticketsButtonKey)); + await tester.pumpAndSettle(); + expect(lastLaunchedTicketsUrl, 'https://finnkino.fi/test-tickets-url'); }, ); @@ -108,15 +120,12 @@ void main() { (WidgetTester tester) async { await _buildEventDetailsPage( tester, - trailers: ['https://youtube.com/?v=test-trailer'], - show: new Show( - start: new DateTime(2018), - theaterAndAuditorium: 'Test theater', - ), + trailers: ['https://youtube.com/?v=test-trailer'], + show: show, ); + await tester.pump(); await tester.tap(find.byKey(EventPoster.playButtonKey)); - await tester.pumpAndSettle(); expect(lastLaunchedTrailerUrl, 'https://youtube.com/?v=test-trailer'); }, diff --git a/test/ui/events/events_page_test.dart b/mobile/test/ui/events/events_page_test.dart similarity index 66% rename from test/ui/events/events_page_test.dart rename to mobile/test/ui/events/events_page_test.dart index 873adc0a..491c5e46 100644 --- a/test/ui/events/events_page_test.dart +++ b/mobile/test/ui/events/events_page_test.dart @@ -1,70 +1,64 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/actor.dart'; -import 'package:inkino/data/models/event.dart'; +import 'package:image_test_utils/image_test_utils.dart'; +import 'package:inkino/main.dart'; import 'package:inkino/ui/common/info_message_view.dart'; import 'package:inkino/ui/common/loading_view.dart'; import 'package:inkino/ui/event_details/event_details_page.dart'; import 'package:inkino/ui/events/event_grid.dart'; import 'package:inkino/ui/events/events_page.dart'; -import 'package:inkino/ui/events/events_page_view_model.dart'; import 'package:mockito/mockito.dart'; -import '../../test_utils.dart'; - class MockEventsPageViewModel extends Mock implements EventsPageViewModel {} -class NavigatorPushObserver extends NavigatorObserver { - Route lastPushedRoute; - - void reset() => lastPushedRoute = null; - - @override - void didPush(Route route, Route previousRoute) { - lastPushedRoute = route; - } -} +class MockNavigatorObserver extends Mock implements NavigatorObserver {} void main() { group('EventGrid', () { - final List events = [ - new Event( + final events = [ + Event( id: '1', title: 'Test Title', genres: 'Test Genres', - directors: [], - actors: [], - images: new EventImageData.empty(), - youtubeTrailers: [], + directors: [], + actors: [], + images: EventImageData.empty(), + youtubeTrailers: [], + galleryImages: [], ), ]; - NavigatorPushObserver observer; + MockNavigatorObserver observer; EventsPageViewModel mockViewModel; setUp(() { - mockAllImageResponses(); - - observer = new NavigatorPushObserver(); - mockViewModel = new MockEventsPageViewModel(); + observer = MockNavigatorObserver(); + mockViewModel = MockEventsPageViewModel(); when(mockViewModel.refreshEvents).thenReturn(() {}); }); - Future _buildEventsPage(WidgetTester tester) { - return tester.pumpWidget(new MaterialApp( - home: new EventsPageContent(mockViewModel), - navigatorObservers: [observer], - )); + Future _buildEventsPage(WidgetTester tester) { + return provideMockedNetworkImages(() { + return tester.pumpWidget( + MaterialApp( + supportedLocales: supportedLocales, + localizationsDelegates: localizationsDelegates, + home: EventsPageContent(mockViewModel, EventListType.nowInTheaters), + navigatorObservers: [observer], + ), + ); + }); } testWidgets( 'when there are no events, should show empty view', (WidgetTester tester) async { when(mockViewModel.status).thenReturn(LoadingStatus.success); - when(mockViewModel.events).thenReturn([]); + when(mockViewModel.events).thenReturn([]); await _buildEventsPage(tester); + await tester.pumpAndSettle(); expect(find.byKey(EventGrid.emptyViewKey), findsOneWidget); expect(find.byKey(EventGrid.contentKey), findsNothing); @@ -81,6 +75,7 @@ void main() { when(mockViewModel.events).thenReturn(events); await _buildEventsPage(tester); + await tester.pumpAndSettle(); expect(find.byKey(EventGrid.contentKey), findsOneWidget); expect(find.byKey(EventGrid.emptyViewKey), findsNothing); @@ -98,16 +93,16 @@ void main() { when(mockViewModel.events).thenReturn(events); await _buildEventsPage(tester); + await tester.pumpAndSettle(); - // Building the events page makes the last pushed route non-null, - // so we'll reset at this point. - observer.reset(); - expect(observer.lastPushedRoute, isNull); + // Building the events page should trigger the navigator observer + // once. + verify(observer.didPush(any, any)); await tester.tap(find.text('Test Title')); await tester.pumpAndSettle(); - expect(observer.lastPushedRoute, isNotNull); + verify(observer.didPush(any, any)); expect(find.byType(EventDetailsPage), findsOneWidget); }, ); @@ -116,9 +111,10 @@ void main() { 'when clicking "try again" on the error view, should call refreshEvents on the view model', (WidgetTester tester) async { when(mockViewModel.status).thenReturn(LoadingStatus.error); - when(mockViewModel.events).thenReturn([]); + when(mockViewModel.events).thenReturn([]); await _buildEventsPage(tester); + await tester.pumpAndSettle(); LoadingViewState state = tester.state(find.byType(LoadingView)); expect(state.errorContentVisible, isTrue); diff --git a/test/ui/showtimes/showtime_date_selector_test.dart b/mobile/test/ui/showtimes/showtime_date_selector_test.dart similarity index 63% rename from test/ui/showtimes/showtime_date_selector_test.dart rename to mobile/test/ui/showtimes/showtime_date_selector_test.dart index 77b79ef6..c3dc2bff 100644 --- a/test/ui/showtimes/showtime_date_selector_test.dart +++ b/mobile/test/ui/showtimes/showtime_date_selector_test.dart @@ -1,7 +1,7 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkino/ui/showtimes/showtime_date_selector.dart'; -import 'package:inkino/ui/showtimes/showtime_page_view_model.dart'; import 'package:mockito/mockito.dart'; class MockShowtimesPageViewModel extends Mock @@ -9,20 +9,20 @@ class MockShowtimesPageViewModel extends Mock void main() { group('ShowtimeDateSelector', () { - final List dates = [ - new DateTime(2018, 1, 1), - new DateTime(2018, 1, 2), + final dates = [ + DateTime(2018, 1, 1), + DateTime(2018, 1, 2), ]; MockShowtimesPageViewModel mockViewModel; setUp(() { - mockViewModel = new MockShowtimesPageViewModel(); + mockViewModel = MockShowtimesPageViewModel(); }); - Future _buildDateSelector(WidgetTester tester) { - return tester.pumpWidget(new MaterialApp( - home: new ShowtimeDateSelector(mockViewModel), + Future _buildDateSelector(WidgetTester tester) { + return tester.pumpWidget(MaterialApp( + home: ShowtimeDateSelector(mockViewModel), )); } @@ -42,18 +42,18 @@ void main() { testWidgets( 'when tapping a date, calls changeCurrentDate on the viewmodel with new date', (WidgetTester tester) async { + DateTime date; + when(mockViewModel.dates).thenReturn(dates); + when(mockViewModel.changeCurrentDate) + .thenReturn((newDate) => date = newDate); await _buildDateSelector(tester); - await tester.tap(find.text('Tue')); - DateTime newDateTime = - verify(mockViewModel.changeCurrentDate(captureAny)).captured.single; - - expect(newDateTime.year, 2018); - expect(newDateTime.month, 1); - expect(newDateTime.day, 2); + expect(date.year, 2018); + expect(date.month, 1); + expect(date.day, 2); }, ); }); diff --git a/test/ui/showtimes/showtimes_page_test.dart b/mobile/test/ui/showtimes/showtimes_page_test.dart similarity index 74% rename from test/ui/showtimes/showtimes_page_test.dart rename to mobile/test/ui/showtimes/showtimes_page_test.dart index d4febb0a..d8e63942 100644 --- a/test/ui/showtimes/showtimes_page_test.dart +++ b/mobile/test/ui/showtimes/showtimes_page_test.dart @@ -1,11 +1,10 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:inkino/data/loading_status.dart'; -import 'package:inkino/data/models/show.dart'; +import 'package:inkino/main.dart'; import 'package:inkino/ui/common/info_message_view.dart'; import 'package:inkino/ui/common/loading_view.dart'; import 'package:inkino/ui/showtimes/showtime_list.dart'; -import 'package:inkino/ui/showtimes/showtime_page_view_model.dart'; import 'package:inkino/ui/showtimes/showtimes_page.dart'; import 'package:mockito/mockito.dart'; @@ -17,18 +16,20 @@ void main() { MockShowtimesPageViewModel mockViewModel; setUp(() { - mockViewModel = new MockShowtimesPageViewModel(); + mockViewModel = MockShowtimesPageViewModel(); when(mockViewModel.status).thenReturn(LoadingStatus.loading); - when(mockViewModel.dates).thenReturn([]); - when(mockViewModel.selectedDate).thenReturn(new DateTime(2018)); - when(mockViewModel.shows).thenReturn([]); + when(mockViewModel.dates).thenReturn([]); + when(mockViewModel.selectedDate).thenReturn(DateTime(2018)); + when(mockViewModel.shows).thenReturn([]); when(mockViewModel.refreshShowtimes).thenReturn(() {}); }); - Future _buildShowtimesPage(WidgetTester tester) { + Future _buildShowtimesPage(WidgetTester tester) { return tester.pumpWidget( - new MaterialApp( - home: new ShowtimesPageContent(mockViewModel), + MaterialApp( + supportedLocales: supportedLocales, + localizationsDelegates: localizationsDelegates, + home: ShowtimesPageContent(mockViewModel), ), ); } @@ -37,9 +38,10 @@ void main() { 'when there are no shows, should show empty view', (WidgetTester tester) async { when(mockViewModel.status).thenReturn(LoadingStatus.success); - when(mockViewModel.shows).thenReturn([]); + when(mockViewModel.shows).thenReturn([]); await _buildShowtimesPage(tester); + await tester.pumpAndSettle(); expect(find.byKey(ShowtimeList.emptyViewKey), findsOneWidget); expect(find.byKey(ShowtimeList.contentKey), findsNothing); @@ -52,17 +54,18 @@ void main() { testWidgets('when shows exist, should show them', (WidgetTester tester) async { when(mockViewModel.status).thenReturn(LoadingStatus.success); - when(mockViewModel.shows).thenReturn([ - new Show( + when(mockViewModel.shows).thenReturn([ + Show( title: 'Show title', theaterAndAuditorium: 'Auditorium One', presentationMethod: '2D', - start: new DateTime(2018), - end: new DateTime(2018), + start: DateTime(2018), + end: DateTime(2018), ), ]); await _buildShowtimesPage(tester); + await tester.pumpAndSettle(); expect(find.byKey(ShowtimeList.contentKey), findsOneWidget); expect(find.byKey(ShowtimeList.emptyViewKey), findsNothing); @@ -78,11 +81,13 @@ void main() { when(mockViewModel.status).thenReturn(LoadingStatus.error); await _buildShowtimesPage(tester); + await tester.pumpAndSettle(); LoadingViewState state = tester.state(find.byType(LoadingView)); expect(state.errorContentVisible, isTrue); await tester.tap(find.byKey(ErrorView.tryAgainButtonKey)); + await tester.pumpAndSettle(); verify(mockViewModel.refreshShowtimes); }, ); diff --git a/test/ui/theater_list/theater_list_test.dart b/mobile/test/ui/theater_list/theater_list_test.dart similarity index 66% rename from test/ui/theater_list/theater_list_test.dart rename to mobile/test/ui/theater_list/theater_list_test.dart index f734ac15..3ec658d8 100644 --- a/test/ui/theater_list/theater_list_test.dart +++ b/mobile/test/ui/theater_list/theater_list_test.dart @@ -1,34 +1,32 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:inkino/data/models/theater.dart'; import 'package:inkino/ui/theater_list/theater_list.dart'; -import 'package:inkino/ui/theater_list/theater_list_view_model.dart'; import 'package:mockito/mockito.dart'; class MockTheaterListViewModel extends Mock implements TheaterListViewModel {} void main() { group('TheaterList', () { - final List theaters = [ - new Theater(id: '1', name: 'Test Theater #1'), - new Theater(id: '2', name: 'Test Theater #2'), + final theaters = [ + Theater(id: '1', name: 'Test Theater #1'), + Theater(id: '2', name: 'Test Theater #2'), ]; MockTheaterListViewModel mockViewModel; bool theaterTappedCallbackCalled; setUp(() { - mockViewModel = new MockTheaterListViewModel(); + mockViewModel = MockTheaterListViewModel(); when(mockViewModel.currentTheater).thenReturn(null); - when(mockViewModel.theaters).thenReturn([]); + when(mockViewModel.theaters).thenReturn([]); theaterTappedCallbackCalled = false; }); - Future _buildTheaterList(WidgetTester tester) { - return tester.pumpWidget(new MaterialApp( - home: new TheaterListContent( - header: new Container(), + Future _buildTheaterList(WidgetTester tester) { + return tester.pumpWidget(MaterialApp( + home: TheaterListContent( onTheaterTapped: () { theaterTappedCallbackCalled = true; }, @@ -53,19 +51,17 @@ void main() { testWidgets( 'when theater tapped, should call both changeCurrentTheater and onTheaterTapped', (WidgetTester tester) async { + Theater theater; when(mockViewModel.currentTheater).thenReturn(theaters.first); when(mockViewModel.theaters).thenReturn(theaters); + when(mockViewModel.changeCurrentTheater) + .thenReturn((newTheater) => theater = newTheater); await _buildTheaterList(tester); - await tester.tap(find.text('Test Theater #2')); - Theater newTheater = - verify(mockViewModel.changeCurrentTheater(captureAny)) - .captured - .first; - expect(newTheater.id, '2'); - expect(newTheater.name, 'Test Theater #2'); + expect(theater.id, '2'); + expect(theater.name, 'Test Theater #2'); expect(theaterTappedCallbackCalled, isTrue); }, diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index 29ae33b8..00000000 --- a/pubspec.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: inkino -description: A new Flutter application. - -dependencies: - flutter: - sdk: flutter - xml: ^2.6.0 - flutter_redux: ^0.3.5 - intl: ^0.15.2 - url_launcher: ^2.0.1 - path_provider: ^0.3.1 - connectivity: ^0.2.1 - shared_preferences: ^0.4.0 - -dev_dependencies: - flutter_test: - sdk: flutter - mockito: ^2.2.2 - -flutter: - uses-material-design: true - assets: - - assets/preloaded_data/theaters.xml - - assets/images/1x1_transparent.png - - assets/images/powered_by_tmdb.png \ No newline at end of file diff --git a/test/data/models/event_test.dart b/test/data/models/event_test.dart deleted file mode 100644 index 90f67c1c..00000000 --- a/test/data/models/event_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:io'; - -import 'package:inkino/data/models/event.dart'; -import 'package:test/test.dart'; - -void main() { - group('Event model', () { - test('parsing tests', () { - var events = new File('test_assets/events.xml').readAsStringSync(); - - List deserialized = Event.parseAll(events); - expect(deserialized.length, 3); - - var paris1517 = deserialized.first; - expect(paris1517.id, '302535'); - expect(paris1517.title, '15:17 Pariisiin'); - expect(paris1517.originalTitle, 'The 15:17 to Paris'); - expect(paris1517.genres, 'Draama, Jännitys'); - expect(paris1517.directors.length, 1); - expect(paris1517.directors.first, 'Clint Eastwood'); - expect(paris1517.actors.length, 11); - expect(paris1517.actors.first.name, 'Anthony Sadler'); - expect(paris1517.lengthInMinutes, '94'); - expect(paris1517.shortSynopsis, 'Short synopsis goes here.'); - expect(paris1517.synopsis, 'Synopsis goes here.'); - expect(paris1517.youtubeTrailers.length, 1); - expect(paris1517.youtubeTrailers.first, 'https://youtube.com/watch?v=oFa4C6OcuM4'); - expect( - paris1517.images.portraitSmall, - 'http://media.finnkino.fi/1012/Event_11881/portrait_small/The1517toParis_1080.jpg', - ); - }); - }); -} \ No newline at end of file diff --git a/test/data/models/show_test.dart b/test/data/models/show_test.dart deleted file mode 100644 index 7dd742c2..00000000 --- a/test/data/models/show_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:io'; - -import 'package:inkino/data/models/show.dart'; -import 'package:test/test.dart'; - -void main() { - group('Show model', () { - test('parsing test', () { - var shows = new File('test_assets/schedule.xml').readAsStringSync(); - - List deserialized = Show.parseAll(shows); - expect(deserialized.length, 3); - - var jumanji = deserialized.first; - expect(jumanji.id, '1155306'); - expect(jumanji.eventId, '302419'); - expect(jumanji.title, 'Jumanji: Welcome to the Jungle'); - expect(jumanji.originalTitle, 'Jumanji: Welcome to the Jungle (Original title)'); - expect(jumanji.url, 'http://www.finnkino.fi/websales/show/1155306/'); - expect(jumanji.presentationMethod, '2D'); - expect(jumanji.theaterAndAuditorium, 'Tennispalatsi, Helsinki, sali 6'); - expect(jumanji.start, new DateTime(2018, 02, 21, 10, 30)); - expect(jumanji.end, new DateTime(2018, 02, 21, 12, 39)); - }); - }); -} \ No newline at end of file diff --git a/test/redux/event/event_middleware_test.dart b/test/redux/event/event_middleware_test.dart deleted file mode 100644 index e8337994..00000000 --- a/test/redux/event/event_middleware_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:inkino/data/models/event.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/event/event_actions.dart'; -import 'package:inkino/redux/event/event_middleware.dart'; -import 'package:mockito/mockito.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - group('EventMiddleware', () { - final Theater theater = new Theater(id: 'test', name: 'Test Theater'); - final List actionLog = []; - final Function(dynamic) next = (action) => actionLog.add(action); - - MockFinnkinoApi mockFinnkinoApi; - EventMiddleware sut; - - List nowInTheatersEvents = [ - new Event(), - new Event(), - new Event(), - ]; - - List upcomingEvents = [ - new Event(), - new Event(), - new Event(), - ]; - - setUp(() { - mockFinnkinoApi = new MockFinnkinoApi(); - sut = new EventMiddleware(mockFinnkinoApi); - }); - - tearDown(() { - actionLog.clear(); - }); - - test( - 'when called with InitCompleteAction, should dispatch a ReceivedEventsAction with now playing and upcoming events', - () async { - when(mockFinnkinoApi.getNowInTheatersEvents(any)) - .thenReturn(nowInTheatersEvents); - when(mockFinnkinoApi.getUpcomingEvents()).thenReturn(upcomingEvents); - - await sut.call(null, new InitCompleteAction(null, theater), next); - - expect(actionLog.length, 3); - expect(actionLog[0], new isInstanceOf()); - expect(actionLog[1], new isInstanceOf()); - - final ReceivedEventsAction action = actionLog[2]; - expect(action.nowInTheatersEvents, nowInTheatersEvents); - expect(action.comingSoonEvents, upcomingEvents); - }, - ); - - test( - 'when called with ChangeCurrentTheaterAction, should request events for the new theater', - () async { - when(mockFinnkinoApi.getNowInTheatersEvents(any)) - .thenReturn(nowInTheatersEvents); - when(mockFinnkinoApi.getUpcomingEvents()).thenReturn(upcomingEvents); - - await sut.call( - null, - new ChangeCurrentTheaterAction( - new Theater( - id: 'changed', - name: 'A newly selected theater', - ), - ), - next, - ); - - Theater captured = - verify(mockFinnkinoApi.getNowInTheatersEvents(captureAny)) - .captured - .first; - expect(captured.id, 'changed'); - expect(captured.name, 'A newly selected theater'); - }, - ); - - test( - 'when InitCompleteAction results in an error, should dispatch an ErrorLoadingEventsAction', - () async { - when(mockFinnkinoApi.getNowInTheatersEvents(any)) - .thenReturn(new Error()); - when(mockFinnkinoApi.getUpcomingEvents()).thenReturn(new Error()); - - await sut.call(null, new InitCompleteAction(null, theater), next); - - expect(actionLog.length, 3); - expect(actionLog[0], new isInstanceOf()); - expect(actionLog[1], new isInstanceOf()); - expect(actionLog[2], new isInstanceOf()); - }, - ); - }); -} diff --git a/test/redux/show/show_middleware_test.dart b/test/redux/show/show_middleware_test.dart deleted file mode 100644 index 62d634b7..00000000 --- a/test/redux/show/show_middleware_test.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:inkino/data/models/show.dart'; -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/app/app_state.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/show/show_actions.dart'; -import 'package:inkino/redux/show/show_middleware.dart'; -import 'package:inkino/redux/theater/theater_state.dart'; -import 'package:inkino/utils/clock.dart'; -import 'package:mockito/mockito.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - group('ShowMiddleware', () { - final DateTime startOf2018 = new DateTime(2018); - final Theater theater = new Theater(id: 'abc123', name: 'Test Theater'); - final List actionLog = []; - final Function(dynamic) next = (action) => actionLog.add(action); - - MockFinnkinoApi mockFinnkinoApi; - MockStore mockStore; - ShowMiddleware sut; - - AppState _theaterState({Theater currentTheater}) { - return new AppState.initial().copyWith( - theaterState: new TheaterState.initial().copyWith( - currentTheater: currentTheater, - ), - ); - } - - setUp(() { - mockFinnkinoApi = new MockFinnkinoApi(); - mockStore = new MockStore(); - sut = new ShowMiddleware(mockFinnkinoApi); - - // Given - when(mockStore.state).thenReturn(_theaterState(currentTheater: theater)); - }); - - tearDown(() { - actionLog.clear(); - Clock.resetDateTimeGetter(); - }); - - test( - 'when called with InitCompleteAction, should dispatch a ReceivedShowsAction with all shows', - () async { - // The middleware filters shows based on if the showtime has already - // passed. As new DateTime(2018) will mean the very first hour and minute - // in January, all the show times in test assets will be after this date. - Clock.getCurrentTime = () => startOf2018; - when(mockFinnkinoApi.getSchedule(theater, any)).thenReturn([ - new Show(start: new DateTime(2018, 02, 21)), - new Show(start: new DateTime(2018, 02, 21)), - new Show(start: new DateTime(2018, 03, 21)), - ]); - - // When - await sut.call(mockStore, new InitCompleteAction(null, theater), next); - - // Then - verify(mockFinnkinoApi.getSchedule(theater, null)); - - expect(actionLog.length, 3); - expect(actionLog[0], new isInstanceOf()); - expect(actionLog[1], new isInstanceOf()); - - final ReceivedShowsAction receivedShowsAction = actionLog[2]; - expect(receivedShowsAction.shows.length, 3); - }, - ); - - test( - 'when called with ChangeCurrentDateAction, should dispatch a ReceivedShowsAction with only relevant shows', - () async { - // Given - Clock.getCurrentTime = () => new DateTime(2018, 3); - when(mockFinnkinoApi.getSchedule(theater, any)).thenReturn([ - new Show(start: new DateTime(2018, 02, 21)), - new Show(start: new DateTime(2018, 02, 21)), - new Show(start: new DateTime(2018, 03, 21)), - ]); - - // When - await sut.call( - mockStore, new ChangeCurrentDateAction(startOf2018), next); - - // Then - verify(mockFinnkinoApi.getSchedule(theater, startOf2018)); - - expect(actionLog.length, 3); - expect(actionLog[0], new isInstanceOf()); - expect(actionLog[1], new isInstanceOf()); - - final ReceivedShowsAction receivedShowsAction = actionLog[2]; - expect(receivedShowsAction.shows.length, 1); - }, - ); - - test( - 'when InitCompleteAction results in an error, should dispatch an ErrorLoadingShowsAction', - () async { - // Given - when(mockFinnkinoApi.getSchedule(any, any)).thenReturn(new Error()); - - // When - await sut.call(mockStore, new InitCompleteAction(null, theater), next); - - // Then - expect(actionLog.length, 3); - expect(actionLog[0], new isInstanceOf()); - expect(actionLog[1], new isInstanceOf()); - expect(actionLog[2], new isInstanceOf()); - }, - ); - }); -} diff --git a/test/redux/theater/theater_middleware_test.dart b/test/redux/theater/theater_middleware_test.dart deleted file mode 100644 index 98493a3a..00000000 --- a/test/redux/theater/theater_middleware_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:inkino/data/models/theater.dart'; -import 'package:inkino/redux/common_actions.dart'; -import 'package:inkino/redux/theater/theater_middleware.dart'; -import 'package:mockito/mockito.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - group('TheaterMiddleware', () { - final List log = []; - final Function(dynamic) next = (action) => log.add(action); - - MockAssetBundle mockAssetBundle; - MockPreferences mockPreferences; - TheaterMiddleware sut; - - Future _theaterXml() => - new File('test_assets/theaters.xml').readAsString(); - - setUp(() { - mockAssetBundle = new MockAssetBundle(); - mockPreferences = new MockPreferences(); - sut = new TheaterMiddleware(mockAssetBundle, mockPreferences); - }); - - tearDown(() { - log.clear(); - }); - - group('called with InitAction', () { - test('loads the preloaded theaters', () async { - // Given - when(mockAssetBundle.loadString(any)).thenReturn(_theaterXml()); - - // When - await sut.call(null, new InitAction(), next); - - // Then - final InitCompleteAction action = log.single; - expect(action.theaters.length, 3); - }); - - test('when a persisted theater id exists, uses that as a default', - () async { - when(mockAssetBundle.loadString(any)).thenReturn(_theaterXml()); - when(mockPreferences.getString(TheaterMiddleware.kDefaultTheaterId)) - .thenReturn('001'); - - await sut.call(null, new InitAction(), next); - - final InitCompleteAction action = log.single; - Theater theater = action.selectedTheater; - expect(theater.id, '001'); - expect(theater.name, 'Gotham: Theater One'); - }); - - test( - 'when no persisted theater id, uses the first theater as a default', - () async { - when(mockAssetBundle.loadString(any)).thenReturn(_theaterXml()); - when(mockPreferences.getString(TheaterMiddleware.kDefaultTheaterId)) - .thenReturn(null); - - await sut.call(null, new InitAction(), next); - - final InitCompleteAction action = log.single; - Theater theater = action.selectedTheater; - expect(theater.id, '1029'); - expect(theater.name, 'All theaters'); - }, - ); - }); - - test( - 'when called with ChangeCurrentTheaterAction, persists and dispatches the same action', - () async { - var theater = new Theater(id: 'test-123', name: 'Test Theater'); - await sut.call(null, new ChangeCurrentTheaterAction(theater), next); - - verify(mockPreferences.setString( - TheaterMiddleware.kDefaultTheaterId, 'test-123')); - - final ChangeCurrentTheaterAction action = log.single; - expect(action.selectedTheater, theater); - }, - ); - }); -} diff --git a/test/test_utils.dart b/test/test_utils.dart deleted file mode 100644 index 1e0ee54f..00000000 --- a/test/test_utils.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart' as http; - -/// This replaces the built-in HTTP client with a mocked one. The mocked client -/// will always return a transparent image. -/// -/// This is a workaround needed for widget tests that use network images, -/// otherwise the test will crash. -/// -/// For more context: -/// -/// * https://github.com/flutter/flutter/issues/13433 -/// * https://github.com/flutter/flutter_markdown/pull/17 -void mockAllImageResponses() { - createHttpClient = createMockImageHttpClient; -} - -ValueGetter createMockImageHttpClient = () { - return new http.MockClient((http.BaseRequest request) { - return new Future.value( - new http.Response.bytes(_transparentImage, 200, request: request), - ); - }); -}; - -const List _transparentImage = const [ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, - 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, - 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, - 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE -]; diff --git a/test/utils/event_name_cleaner_test.dart b/test/utils/event_name_cleaner_test.dart deleted file mode 100644 index bcb4d540..00000000 --- a/test/utils/event_name_cleaner_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:inkino/utils/event_name_cleaner.dart'; -import 'package:test/test.dart'; - -void main() { - group('$EventNameCleaner', () { - test('cleans up unneeded noise from movie names', () { - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (3D)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War 2D'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War 3D'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (dub)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D dub)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D orig)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D spanish)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D) (dub)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D) (orig)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (2D) (spanish)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (3D dub)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (3D orig)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (3D spanish)'), 'Avengers: Infinity War'); - expect(EventNameCleaner.cleanup('Avengers: Infinity War (swe)'), 'Avengers: Infinity War'); - - // These should stay the same - expect(EventNameCleaner.cleanup('BPM (beats per minute)'), 'BPM (beats per minute)'); - expect(EventNameCleaner.cleanup('That awesome spanish girl'), 'That awesome spanish girl'); - expect(EventNameCleaner.cleanup('The 3D movie'), 'The 3D movie'); - }); - }); -} \ No newline at end of file diff --git a/test_assets/schedule_dates.xml b/test_assets/schedule_dates.xml deleted file mode 100644 index c4c94c05..00000000 --- a/test_assets/schedule_dates.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - 2018-02-20T00:00:00 - 2011-03-10T00:00:00 - 2013-06-12T00:00:00 - \ No newline at end of file diff --git a/web/analysis_options.yaml b/web/analysis_options.yaml new file mode 100644 index 00000000..8d53f9bf --- /dev/null +++ b/web/analysis_options.yaml @@ -0,0 +1,23 @@ +analyzer: + exclude: [build/**] + errors: + uri_has_not_been_generated: ignore + plugins: + - angular + +# Lint rules and documentation, see http://dart-lang.github.io/linter/lints +linter: + rules: + - cancel_subscriptions + - hash_and_equals + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - test_types_in_equals + - unrelated_type_equality_checks + - valid_regexps + +global_options: + angular|angular: + options: + no-emit-component-factories: True + no-emit-injectable-factories: True diff --git a/web/build.yaml b/web/build.yaml new file mode 100644 index 00000000..d5490ca9 --- /dev/null +++ b/web/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + builders: + build_web_compilers|entrypoint: + options: + dart2js_args: + - --minify + - --omit-implicit-checks + - --fast-startup + - --trust-primitives + - --trust-type-annotations diff --git a/web/deploy.sh b/web/deploy.sh new file mode 100755 index 00000000..c835d50d --- /dev/null +++ b/web/deploy.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Yes, I know this is bad. It uses a custom chunk-based file fingerprinter, +# but it's not ready just yet and has shortcomings that have to be patched +# by this bash script. I'm working on it. +rm -rf build +webdev build +cd build/images && mv * ../ && cd ../ + +dart ../../../fingerprint/bin/fingerprint.dart + +for i in $(find . -type f | egrep '\.(svg|png|jpeg|jpg)$'); do + mv "$i" ./images/$i +done + +cd ../ +# firebase deploy diff --git a/web/firebase.json b/web/firebase.json new file mode 100644 index 00000000..89813a11 --- /dev/null +++ b/web/firebase.json @@ -0,0 +1,22 @@ +{ + "hosting": { + "public": "build", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**", + "**/packages/**" + ], + "headers": [ + { + "source": "**/*.@(jpg|jpeg|gif|png|svg|js)", + "headers": [ + { + "key": "Cache-Control", + "value": "max-age=31536000" + } + ] + } + ] + } +} diff --git a/web/lib/app_component.dart b/web/lib/app_component.dart new file mode 100644 index 00000000..8221153d --- /dev/null +++ b/web/lib/app_component.dart @@ -0,0 +1,51 @@ +import 'dart:html'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/app_bar/app_bar_component.dart'; +import 'package:web/src/common/theater_selector/theater_dropdown_controller.dart'; + +import 'src/routes.dart'; + +@Component( + selector: 'my-app', + styleUrls: ['app_component.css'], + templateUrl: 'app_component.html', + directives: [ + AppBarComponent, + routerDirectives, + ], + exports: [Routes], +) +class AppComponent implements OnInit, AfterContentInit { + AppComponent(this._store, this._loader); + final Store _store; + final ComponentLoader _loader; + + @ViewChild('theaterContainer', read: ViewContainerRef) + ViewContainerRef theaterContainer; + + TheaterDropdownController _theaterController; + bool get theaterDropdownVisible => _theaterController?.visible == true; + bool get theaterDropdownActive => _theaterController?.isDestroyed == false; + + @override + void ngOnInit() => _store.dispatch(InitAction()); + + @override + void ngAfterContentInit() => document.body.classes.add('loaded'); + + void toggleTheaterDropdown() async { + if (!theaterDropdownActive) { + _theaterController = await TheaterDropdownController.loadAndShow( + _loader, + theaterContainer, + background: '#152451', + ); + } else { + _theaterController.hideAndDestroy(); + } + } +} diff --git a/web/lib/app_component.html b/web/lib/app_component.html new file mode 100644 index 00000000..24592f5b --- /dev/null +++ b/web/lib/app_component.html @@ -0,0 +1,13 @@ + + + +
+ + +
+ +
+
\ No newline at end of file diff --git a/web/lib/app_component.scss b/web/lib/app_component.scss new file mode 100644 index 00000000..f8ac9a47 --- /dev/null +++ b/web/lib/app_component.scss @@ -0,0 +1,27 @@ +@import 'src/common'; +@import 'src/breakpoints'; + +main { + margin: 56px 0; + + @include screen-size-tablet { + margin: 60px 20px; + } + + @include screen-size-laptop { + margin: 60px 0; + } +} + +.theater-container { + display: none; + position: fixed; + top: 56px; + right: 0; + bottom: 0; + left: 0; + + &.visible { + display: block; + } +} \ No newline at end of file diff --git a/web/lib/src/_breakpoints.scss b/web/lib/src/_breakpoints.scss new file mode 100644 index 00000000..8e7403ce --- /dev/null +++ b/web/lib/src/_breakpoints.scss @@ -0,0 +1,29 @@ +@mixin screen-size-nexus-5x { + @media only screen and(min-width: 412px) { + @content; + } +} + +@mixin screen-size-phablet { + @media only screen and(min-width: 650px) { + @content; + } +} + +@mixin screen-size-tablet { + @media only screen and (min-width: 768px) { + @content; + } +} + +@mixin screen-size-laptop { + @media only screen and (min-width: 1024px) { + @content; + } +} + +@mixin screen-size-huge { + @media only screen and (min-width: 1800px) { + @content; + } +} \ No newline at end of file diff --git a/web/lib/src/_common.scss b/web/lib/src/_common.scss new file mode 100644 index 00000000..2095d745 --- /dev/null +++ b/web/lib/src/_common.scss @@ -0,0 +1,64 @@ +@import 'breakpoints'; + +.app-bar-button { + width: 56px; + height: 56px; + padding: 14px; + cursor: pointer; + user-select: none; + transition: background-color 250ms ease, opacity 150ms ease, transform 250ms ease; + + &.active { + background: #152451; + } + + @include screen-size-tablet { + width: 60px; + height: 60px; + } +} + +.scrolling-blocked { + position: fixed; + left: 0; + right: 0; + z-index: 1; + overflow: hidden; +} + +.page-title { + display: none; + + @include screen-size-tablet { + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + padding-top: 20px; + + h3 { + color: #ffffff; + font-weight: 600; + font-size: 30px; + text-transform: uppercase; + } + } +} + +@include screen-size-laptop { + .content-wrapper { + margin: 0 auto; + width: 70%; + min-width: 850px; + padding: 100px 0; + } +} + +// Needs the parent to have a position:relative directive. +@mixin full-size-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} \ No newline at end of file diff --git a/web/lib/src/app_bar/app_bar_component.dart b/web/lib/src/app_bar/app_bar_component.dart new file mode 100644 index 00000000..181a156c --- /dev/null +++ b/web/lib/src/app_bar/app_bar_component.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/app_bar/nav_bar/nav_bar_component.dart'; +import 'package:web/src/app_bar/scroll_utils.dart'; +import 'package:web/src/app_bar/search_bar/search_bar_component.dart'; +import 'package:web/src/routes.dart'; + +@Component( + selector: 'app-bar', + templateUrl: 'app_bar_component.html', + styleUrls: ['app_bar_component.css'], + directives: [ + NavBarComponent, + SearchBarComponent, + routerDirectives, + ], + exports: [RoutePaths], +) +class AppBarComponent implements OnInit, OnDestroy { + AppBarComponent(this.messages, this._store, this._router); + + final Messages messages; + final Store _store; + final Router _router; + + String get theaterName => _store.state.theaterState.currentTheater.name; + + @Input() + bool theaterDropdownVisible = false; + + @Input() + bool theaterDropdownActive = false; + + bool hide = false; + bool isEventDetailsPage = false; + + StreamSubscription _routeListener; + Timer _scrollTimer; + + @Output() + Stream get theaterButtonClicked => _theaterButtonClicked.stream; + final _theaterButtonClicked = StreamController(); + + void openTheaterDropdown() => _theaterButtonClicked.add(null); + + @override + void ngOnInit() { + _listenForRoutes(); + _scrollTimer = listenForScrollDirectionChanges((newDirection) { + if (!isEventDetailsPage && !theaterDropdownVisible) { + hide = newDirection == ScrollDirection.down; + } + }); + } + + void _listenForRoutes() { + _routeListener = _router.onRouteActivated.listen((route) { + final path = route.routePath.path; + isEventDetailsPage = path == RoutePaths.eventDetails.path || + path == RoutePaths.showDetails.path; + + hide = isEventDetailsPage; + }); + } + + @override + void ngOnDestroy() { + _theaterButtonClicked.close(); + _routeListener.cancel(); + _scrollTimer.cancel(); + } +} diff --git a/web/lib/src/app_bar/app_bar_component.html b/web/lib/src/app_bar/app_bar_component.html new file mode 100644 index 00000000..7535d6cf --- /dev/null +++ b/web/lib/src/app_bar/app_bar_component.html @@ -0,0 +1,33 @@ +
+
+
+ + + +
+ +
+ Change selected theater + + +
+
+
\ No newline at end of file diff --git a/web/lib/src/app_bar/app_bar_component.scss b/web/lib/src/app_bar/app_bar_component.scss new file mode 100644 index 00000000..f0b60b46 --- /dev/null +++ b/web/lib/src/app_bar/app_bar_component.scss @@ -0,0 +1,120 @@ +@import '../common'; +@import '../breakpoints'; + +header { + background: #1C306D; + box-shadow: 0 10px 60px rgba(0, 0, 0, .4); + width: 100%; + height: 56px; + position: fixed; + top: 0; + opacity: 1; + z-index: 3000; + transition: opacity 350ms ease, top 350ms ease; + + &.hidden { + top: -56px; + opacity: 0; + } +} + +.wrapper { + display: flex; + justify-content: space-between; + align-content: center; + width: 100%; + padding-left: 20px; +} + +.left { + display: flex; +} + +.right { + display: flex; +} + +.logo { + position: relative; + display: flex; + color: #ffffff; + align-items: center; + text-decoration: none; + user-select: none; + cursor: pointer; + + img { + width: 28px; + height: 28px; + margin-top: 2px; + } + + h1 { + font-size: 20px; + font-weight: 500; + color: #FEFEFE; + } + + .mobile-logo-focus-trap { + @include full-size-overlay; + } +} + +.name-and-selected-theater { + display: flex; + flex-direction: column; + margin-left: 6px; +} + +.theater-name { + font-size: 12px; + opacity: 0.7; + white-space: nowrap; + text-overflow: ellipsis; +} + +@include screen-size-tablet { + header { + height: 60px; + + &.hidden { + top: -60px; + } + } + + .theater-name { + display: none; + } + + .name-and-selected-theater { + margin-left: 10px; + } + + .logo { + img { + width: 32px; + height: 32px; + } + + h1 { + font-size: 30px; + } + } + + .mobile-logo-focus-trap { + display: none; + } + + .app-bar-button.select-theater { + display: none; + } +} + +@include screen-size-laptop { + .wrapper { + width: 70%; + min-width: 850px; + margin: 0 auto; + padding: 0; + } +} \ No newline at end of file diff --git a/web/lib/src/app_bar/nav_bar/nav_bar_component.dart b/web/lib/src/app_bar/nav_bar/nav_bar_component.dart new file mode 100644 index 00000000..3ff8d494 --- /dev/null +++ b/web/lib/src/app_bar/nav_bar/nav_bar_component.dart @@ -0,0 +1,21 @@ +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:web/src/routes.dart'; + +@Component( + selector: 'nav-bar', + templateUrl: 'nav_bar_component.html', + styleUrls: ['nav_bar_component.css'], + directives: [ + routerDirectives, + ], + exports: [RoutePaths], +) +class NavBarComponent { + NavBarComponent(this.messages); + final Messages messages; + + @Input() + bool theaterDropdownActive = false; +} diff --git a/web/lib/src/app_bar/nav_bar/nav_bar_component.html b/web/lib/src/app_bar/nav_bar/nav_bar_component.html new file mode 100644 index 00000000..2506ec7c --- /dev/null +++ b/web/lib/src/app_bar/nav_bar/nav_bar_component.html @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/web/lib/src/app_bar/nav_bar/nav_bar_component.scss b/web/lib/src/app_bar/nav_bar/nav_bar_component.scss new file mode 100644 index 00000000..95044e23 --- /dev/null +++ b/web/lib/src/app_bar/nav_bar/nav_bar_component.scss @@ -0,0 +1,87 @@ +@import '../../breakpoints'; + +nav { + position: fixed; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.5); + bottom: 0; + left: 0; + display: flex; + justify-content: space-around; + background: rgba(0, 0, 0, 0.8); + width: 100%; + height: 68px; + opacity: 1; + transition: opacity 350ms ease, bottom 350ms ease; + + &.hidden { + opacity: 0; + bottom: -60px; + } +} + +nav a { + padding: 10px 16px 2px 16px; + text-decoration: none; + text-align: center; + user-select: none; + + .icon { + width: 28px; + height: 28px; + opacity: 0.6; + transition: opacity 0.15s linear; + } + + span { + display: flex; + align-items: center; + color: rgba(255, 255, 255, 0.6); + padding: 0 2px 5px 2px; + font-size: 14px; + transition: border-bottom-width 0.15s linear, padding-bottom 0.15s linear, color 0.15s linear; + } + + &.active-route { + color: #fff; + padding-bottom: 0; + + .icon { + opacity: 1; + } + + span { + padding-bottom: 0; + color: #ffffff; + } + } +} + +@include screen-size-tablet { + nav { + margin-left: 40px; + height: 60px; + position: unset; + box-shadow: none; + justify-content: unset; + background: unset; + width: unset; + + a { + padding-bottom: 0; + + span { + height: 100%; + font-size: 16px; + border-bottom: 0 solid #fdbd2c; + } + } + + .icon { + display: none; + } + + .active-route span { + border-bottom-width: 5px; + } + } +} \ No newline at end of file diff --git a/web/lib/src/app_bar/scroll_utils.dart b/web/lib/src/app_bar/scroll_utils.dart new file mode 100644 index 00000000..1e910185 --- /dev/null +++ b/web/lib/src/app_bar/scroll_utils.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:html'; + +enum ScrollDirection { up, down } + +typedef void ScrollDirectionChangedCallback(ScrollDirection newDirection); + +Timer listenForScrollDirectionChanges(ScrollDirectionChangedCallback callback) { + var previousTop = 0; + + return Timer.periodic(const Duration(milliseconds: 250), (_) { + final top = document.body.getBoundingClientRect().top; + + if (top > previousTop || top > -160) { + callback(ScrollDirection.up); + } else if (top < previousTop) { + callback(ScrollDirection.down); + } + + previousTop = top; + }); +} diff --git a/web/lib/src/app_bar/search_bar/search_bar_component.dart b/web/lib/src/app_bar/search_bar/search_bar_component.dart new file mode 100644 index 00000000..2e11ffc2 --- /dev/null +++ b/web/lib/src/app_bar/search_bar/search_bar_component.dart @@ -0,0 +1,39 @@ +import 'dart:async'; +import 'dart:html'; + +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; + +@Component( + selector: 'search-bar', + templateUrl: 'search_bar_component.html', + styleUrls: ['search_bar_component.css'], +) +class SearchBarComponent { + SearchBarComponent(this.messages, this.store); + final Messages messages; + final Store store; + + @HostBinding('attr.expanded') + String get hostExpanded => searchOpen ? '' : null; + + @ViewChild('searchField') + InputElement searchField; + + bool searchOpen = false; + + void toggleSearch() { + searchOpen = !searchOpen; + + if (searchOpen) { + Timer(const Duration(milliseconds: 250), () => searchField.focus()); + } else { + searchField.value = ''; // Clear the search when closed. + updateSearchQuery(null); + } + } + + void updateSearchQuery(String newQuery) => + store.dispatch(SearchQueryChangedAction(newQuery)); +} diff --git a/web/lib/src/app_bar/search_bar/search_bar_component.html b/web/lib/src/app_bar/search_bar/search_bar_component.html new file mode 100644 index 00000000..24b76d89 --- /dev/null +++ b/web/lib/src/app_bar/search_bar/search_bar_component.html @@ -0,0 +1,16 @@ +Stop searching movies + + + +
+ Search for movies + Stop searching movies +
\ No newline at end of file diff --git a/web/lib/src/app_bar/search_bar/search_bar_component.scss b/web/lib/src/app_bar/search_bar/search_bar_component.scss new file mode 100644 index 00000000..25db6e2b --- /dev/null +++ b/web/lib/src/app_bar/search_bar/search_bar_component.scss @@ -0,0 +1,77 @@ +@import '../../common'; +@import '../../breakpoints'; + +:host { + display: flex; + background: #1F3169; + + &[expanded] { + position: fixed; + left: 0; + right: 0; + + @include screen-size-tablet { + position: unset; + } + } +} + +.back { + display: none; + + &.visible { + display: block; + padding-left: 16px; + cursor: pointer; + + @include screen-size-tablet { + display: none; + } + } +} + +input { + display: none; + background: transparent; + width: 100%; + color: #ffffff; + font-size: 16px; + padding: 8px; + + &, &:focus { + background-color: transparent; + border: none; + outline: none; + } + + &::placeholder { + color: rgba(255, 255, 255, 0.5); + } + + &.visible { + display: block; + } +} + +.buttons { + position: relative; + width: 56px; + height: 56px; + + @include screen-size-tablet { + width: 60px; + height: 60px; + } +} + +.buttons img { + position: absolute; + opacity: 0; + right: 0; + transform: scale(0.2); + + &.visible { + opacity: 1; + transform: scale(1.0); + } +} \ No newline at end of file diff --git a/web/lib/src/common/content_rating/content_rating_component.dart b/web/lib/src/common/content_rating/content_rating_component.dart new file mode 100644 index 00000000..5657621b --- /dev/null +++ b/web/lib/src/common/content_rating/content_rating_component.dart @@ -0,0 +1,22 @@ +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; + +@Component( + selector: 'content-rating', + templateUrl: 'content_rating_component.html', + styleUrls: ['content_rating_component.css'], + directives: [NgFor], +) +class ContentRatingComponent { + @Input() + Show show; + + @Input() + Event event; + + String get ageRating => show?.ageRating ?? event?.ageRating; + String get ageRatingUrl => show?.ageRatingUrl ?? event?.ageRatingUrl; + + List get contentDescriptors => + show?.contentDescriptors ?? event?.contentDescriptors; +} diff --git a/web/lib/src/common/content_rating/content_rating_component.html b/web/lib/src/common/content_rating/content_rating_component.html new file mode 100644 index 00000000..e36ad4cc --- /dev/null +++ b/web/lib/src/common/content_rating/content_rating_component.html @@ -0,0 +1,2 @@ + + diff --git a/web/lib/src/common/content_rating/content_rating_component.scss b/web/lib/src/common/content_rating/content_rating_component.scss new file mode 100644 index 00000000..35f7d66a --- /dev/null +++ b/web/lib/src/common/content_rating/content_rating_component.scss @@ -0,0 +1,31 @@ +$small: 20px; +$medium: 30px; +$default: $small; + +@mixin _size($size) { + img { + width: $size; + height: $size; + } +} + +:host { + &[size="small"] { + @include _size($small); + } + + &[size="medium"] { + @include _size($medium); + } +} + +img { + width: $default; + height: $default; + vertical-align: middle; + margin-right: 6px; + + &:last-child { + margin-right: 0; + } +} \ No newline at end of file diff --git a/web/lib/src/common/event_poster/event_poster_component.dart b/web/lib/src/common/event_poster/event_poster_component.dart new file mode 100644 index 00000000..e298bf0a --- /dev/null +++ b/web/lib/src/common/event_poster/event_poster_component.dart @@ -0,0 +1,37 @@ +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:intl/intl.dart'; +import 'package:web/src/common/content_rating/content_rating_component.dart'; +import 'package:web/src/common/event_poster/lazy_image_component.dart'; + +@Component( + selector: 'event-poster', + styleUrls: ['event_poster_component.css'], + templateUrl: 'event_poster_component.html', + directives: [ + ContentRatingComponent, + LazyImageComponent, + NgIf, + NgFor, + ], +) +class EventPosterComponent { + static final _releaseDateFormat = DateFormat('dd.MM.yyyy'); + + EventPosterComponent(this.messages); + final Messages messages; + + @Input() + Event event; + + @Input() + bool isComingSoon = false; + + @Input() + bool hasDetails = true; + + @Input() + bool isTouchable = true; + + String get releaseDate => _releaseDateFormat.format(event.releaseDate); +} diff --git a/web/lib/src/common/event_poster/event_poster_component.html b/web/lib/src/common/event_poster/event_poster_component.html new file mode 100644 index 00000000..17ea8d41 --- /dev/null +++ b/web/lib/src/common/event_poster/event_poster_component.html @@ -0,0 +1,23 @@ +
+ +
+ + + +
+ + + +

{{ event.title }}

+

{{ event.genres }}

+
+ +
+

{{ messages.releaseDate }}

+

{{ releaseDate }}

+
diff --git a/web/lib/src/common/event_poster/event_poster_component.scss b/web/lib/src/common/event_poster/event_poster_component.scss new file mode 100644 index 00000000..bac7f850 --- /dev/null +++ b/web/lib/src/common/event_poster/event_poster_component.scss @@ -0,0 +1,76 @@ +@import '../../common'; +@import '../../breakpoints'; + +:host { + position: relative; +} + +.fallback-icon { + @include full-size-overlay; + + display: flex; + align-items: center; + justify-content: center; + z-index: -1; + background: linear-gradient(#424242, #222222); + + img { + display: block; + width: 70%; + } +} + +.event-information { + @include full-size-overlay; + + display: flex; + flex-direction: column; + justify-content: flex-end; + background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 30%, #000); + padding: 1.2em; + + strong { + font-weight: 500; + font-size: 12pt; + color: #ffffff; + } + + p.genres { + margin-top: 0.2em; + font-size: 10pt; + color: rgba(255, 255, 255, 0.7); + } + + content-rating { + margin-bottom: 10px; + } +} + +.release-date-information { + position: absolute; + top: 10px; + left: 0; + background: rgba(0, 0, 0, 0.8); + padding: 5px 20px 5px 10px; + + .label { + color: #FFBE00; + font-size: 12px; + font-weight: bold; + } + + .date { + color: #FEFEFE; + font-size: 16px; + font-weight: 300; + margin-top: 2px; + } + + @include screen-size-tablet { + padding: 10px 40px 10px 20px; + + .date { + font-size: 20px; + } + } +} \ No newline at end of file diff --git a/web/lib/src/common/event_poster/lazy_image_component.dart b/web/lib/src/common/event_poster/lazy_image_component.dart new file mode 100644 index 00000000..00bdae6f --- /dev/null +++ b/web/lib/src/common/event_poster/lazy_image_component.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:html'; +import 'dart:js' as js; + +import 'package:angular/angular.dart'; + +final bool supportsIntersectionObserver = + js.context.hasProperty('IntersectionObserver'); + +@Component( + selector: 'lazy-img', + template: '', + styleUrls: ['lazy_image_component.css'], +) +class LazyImageComponent implements OnInit { + static const _ratio = 2 / 3; + static const _widthBreakpoints = [160, 206, 300]; + static int _adjustedWidth, _adjustedHeight; + + LazyImageComponent(this.root); + final Element root; + + @Input() + String src; + + @Input() + String alt; + + /// So that the images can be also seen fading in when scrolling + static final onLoad = (image) => Timer( + const Duration(milliseconds: 50), + () => image.style.opacity = '1', + ); + + static final _instance = IntersectionObserver( + js.allowInterop((entries, observer) { + entries.forEach((entry) { + if (entry.isIntersecting && entry.target is ImageElement) { + _loadImage(entry.target as ImageElement); + observer.unobserve(entry.target); + } + }); + }), + ); + + static void _loadImage(ImageElement image, {String src}) { + final url = src ?? image.dataset['src']; + + image + ..src = _urlWithDimensions(url) + ..classes.remove('lazy') + ..addEventListener('load', (_) => onLoad(image)) + ..addEventListener('error', (_) => onLoad(image)); + } + + @override + void ngOnInit() { + final ImageElement image = root.querySelector('img'); + _calculateDimensionsIfNeeded(image); + + if (supportsIntersectionObserver) { + _instance.observe(image); + } else { + /// No IntersectionObserver, tough luck, here's all of your 70 posters + /// at once (╯°□°)╯︵ ┻━┻ + _loadImage(image, src: src); + } + } + + /// TODO: srcsets are probably the way to go instead. + void _calculateDimensionsIfNeeded(ImageElement image) { + if (_adjustedWidth == null || _adjustedHeight == null) { + final clientWidth = image.clientWidth; + + if (clientWidth == null || clientWidth == 0) { + _adjustedWidth = 300; + _adjustedHeight = (_adjustedWidth / _ratio).round(); + return; + } + + final closestWidth = _widthBreakpoints.firstWhere( + (width) => width >= clientWidth, + orElse: () => _widthBreakpoints.last, + ); + + _adjustedWidth = closestWidth; + _adjustedHeight = (_adjustedWidth / _ratio).round(); + } + } + + static String _urlWithDimensions(String url) { + return '${url}&w=${_adjustedWidth}&h=${_adjustedHeight}'; + } +} diff --git a/web/lib/src/common/event_poster/lazy_image_component.scss b/web/lib/src/common/event_poster/lazy_image_component.scss new file mode 100644 index 00000000..755600b8 --- /dev/null +++ b/web/lib/src/common/event_poster/lazy_image_component.scss @@ -0,0 +1,7 @@ +img { + max-width: 100%; + max-height: 100%; + width: 100%; + opacity: 0; + transition: opacity 750ms ease; +} diff --git a/web/lib/src/common/loading_view/loading_view_component.dart b/web/lib/src/common/loading_view/loading_view_component.dart new file mode 100644 index 00000000..7971d3db --- /dev/null +++ b/web/lib/src/common/loading_view/loading_view_component.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:html' as html; + +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:web/src/common/loading_view/spinner_component.dart'; + +@Component( + selector: 'loading-view', + templateUrl: 'loading_view_component.html', + styleUrls: ['loading_view_component.css'], + directives: [ + SpinnerComponent, + NgIf, + ], +) +class LoadingViewComponent implements OnDestroy { + LoadingViewComponent(this.messages); + final Messages messages; + + LoadingStatus _status; + + @Input() + bool contentEmpty = false; + + @Input() + String errorTitle; + + @Input() + String errorMessage; + + String get emptyTitle => contentEmpty ? messages.allEmpty : null; + String get emptyMessage => contentEmpty ? messages.noMoviesForToday : null; + + @Output() + Stream get actionButtonClicked => _tryAgainController.stream; + final _tryAgainController = StreamController(); + + @Input() + set status(LoadingStatus status) { + _clearOutInvisibleContent = false; + _status = status; + + Timer( + const Duration(milliseconds: 450), + () => _clearOutInvisibleContent = true, + ); + } + + bool get loadingContentVisible => _status == LoadingStatus.loading; + bool get loadingContentPresent => + loadingContentVisible || !_clearOutInvisibleContent; + + bool get successContentVisible => _status == LoadingStatus.success; + bool get successContentPresent => + successContentVisible || !_clearOutInvisibleContent; + + bool get errorContentVisible => + _status == LoadingStatus.error || + (_status != LoadingStatus.loading && contentEmpty); + bool get errorContentPresent => + errorContentVisible || !_clearOutInvisibleContent; + + // After animations have finished, all invisible content gets removed from DOM. + bool _clearOutInvisibleContent = false; + + void onTryAgainClicked(html.Event event) { + event.preventDefault(); + _tryAgainController.add(null); + } + + @override + void ngOnDestroy() => _tryAgainController.close(); +} diff --git a/web/lib/src/common/loading_view/loading_view_component.html b/web/lib/src/common/loading_view/loading_view_component.html new file mode 100644 index 00000000..440bb3dd --- /dev/null +++ b/web/lib/src/common/loading_view/loading_view_component.html @@ -0,0 +1,31 @@ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +

{{ emptyTitle ?? errorTitle ?? messages.oops}}

+

+ {{ emptyMessage ?? errorMessage ?? messages.loadingMoviesError }}
+ (this might be caused by your ad blocker) +

+ + + {{ messages.tryAgain }} + +
+
\ No newline at end of file diff --git a/web/lib/src/common/loading_view/loading_view_component.scss b/web/lib/src/common/loading_view/loading_view_component.scss new file mode 100644 index 00000000..040c4487 --- /dev/null +++ b/web/lib/src/common/loading_view/loading_view_component.scss @@ -0,0 +1,79 @@ +.container { + position: relative; + height: 100vh; +} + +.loading-content { + position: absolute; + width: 100%; + height: 75%; + transition: opacity 150ms ease; + + &.visible { + opacity: 1; + } +} + +.error-content { + position: absolute; + width: 100%; + height: 75%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 450ms ease; + + &.visible { + opacity: 1; + } +} + +.success-content { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 450ms ease; + + &.visible { + opacity: 1; + } +} + +.icon { + border-radius: 50px; + background: rgba(255, 255, 255, 0.12); + + img { + display: block; + width: 96px; + height: 96px; + } +} + +.title { + margin-top: 16px; + max-width: 350px; + text-align: center; + font-size: 24px; + color: #ffffff; +} + +.message { + margin-top: 8px; + max-width: 250px; + text-align: center; + color: rgba(255, 255, 255, 0.7); +} + +.try-again { + margin-top: 12px; + color: #ffffff; + padding: 8px; + text-decoration: none; + font-weight: 600; + user-select: none; +} + diff --git a/web/lib/src/common/loading_view/spinner_component.dart b/web/lib/src/common/loading_view/spinner_component.dart new file mode 100644 index 00000000..d9253ac1 --- /dev/null +++ b/web/lib/src/common/loading_view/spinner_component.dart @@ -0,0 +1,9 @@ +import 'package:angular/angular.dart'; + +@Component( + selector: 'spinner', + templateUrl: 'spinner_component.html', + styleUrls: ['spinner_component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +) +class SpinnerComponent {} diff --git a/web/lib/src/common/loading_view/spinner_component.html b/web/lib/src/common/loading_view/spinner_component.html new file mode 100644 index 00000000..a4cc9b59 --- /dev/null +++ b/web/lib/src/common/loading_view/spinner_component.html @@ -0,0 +1,10 @@ +
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/web/lib/src/common/loading_view/spinner_component.scss b/web/lib/src/common/loading_view/spinner_component.scss new file mode 100644 index 00000000..e2a9da7a --- /dev/null +++ b/web/lib/src/common/loading_view/spinner_component.scss @@ -0,0 +1,2 @@ +// From: https://loading.io/css/ +.container{display:inline-block;position:absolute;width:64px;height:64px;top:50%;left:50%;margin-top:-32px;margin-left:-32px}.container div{animation:lds-roller 1.2s cubic-bezier(.5,0,.5,1) infinite;transform-origin:32px 32px}.container div:after{content:" ";display:block;position:absolute;width:6px;height:6px;border-radius:50%;background:#fff;margin:-3px 0 0 -3px}.container div:nth-child(1){animation-delay:-36ms}.container div:nth-child(1):after{top:50px;left:50px}.container div:nth-child(2){animation-delay:-72ms}.container div:nth-child(2):after{top:54px;left:45px}.container div:nth-child(3){animation-delay:-108ms}.container div:nth-child(3):after{top:57px;left:39px}.container div:nth-child(4){animation-delay:-144ms}.container div:nth-child(4):after{top:58px;left:32px}.container div:nth-child(5){animation-delay:-.18s}.container div:nth-child(5):after{top:57px;left:25px}.container div:nth-child(6){animation-delay:-216ms}.container div:nth-child(6):after{top:54px;left:19px}.container div:nth-child(7){animation-delay:-252ms}.container div:nth-child(7):after{top:50px;left:14px}.container div:nth-child(8){animation-delay:-288ms}.container div:nth-child(8):after{top:45px;left:10px}@keyframes lds-roller{0%{transform:rotate(0)}100%{transform:rotate(360deg)}} \ No newline at end of file diff --git a/web/lib/src/common/showtime_item/showtime_item_component.dart b/web/lib/src/common/showtime_item/showtime_item_component.dart new file mode 100644 index 00000000..b618745b --- /dev/null +++ b/web/lib/src/common/showtime_item/showtime_item_component.dart @@ -0,0 +1,25 @@ +import 'dart:html' as html; + +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:web/src/common/content_rating/content_rating_component.dart'; + +@Component( + selector: 'showtime-item', + styleUrls: ['showtime_item_component.css'], + templateUrl: 'showtime_item_component.html', + directives: [ContentRatingComponent, NgIf, NgFor], + pipes: [DatePipe], +) +class ShowtimeItemComponent { + ShowtimeItemComponent(this.messages); + final Messages messages; + + @Input() + Show show; + + void openTickets(html.Event event) { + html.window.open(show.url, 'Tickets for ${show.title}'); + event.stopImmediatePropagation(); + } +} diff --git a/web/lib/src/common/showtime_item/showtime_item_component.html b/web/lib/src/common/showtime_item/showtime_item_component.html new file mode 100644 index 00000000..c374a18b --- /dev/null +++ b/web/lib/src/common/showtime_item/showtime_item_component.html @@ -0,0 +1,24 @@ +
+
+

{{ show.start | date: 'HH:mm' }}

+

{{ show.end | date: 'HH:mm' }}

+
+
+

{{ show.title }}

+
+

{{ show.theaterAndAuditorium }}

+

+ {{ show.presentationMethod }} + +

+
+
+
+ +
+ + + + +
\ No newline at end of file diff --git a/web/lib/src/common/showtime_item/showtime_item_component.scss b/web/lib/src/common/showtime_item/showtime_item_component.scss new file mode 100644 index 00000000..d85b2b39 --- /dev/null +++ b/web/lib/src/common/showtime_item/showtime_item_component.scss @@ -0,0 +1,137 @@ +@import '../../breakpoints'; + +:host { + display: flex; + margin-top: 1px; + cursor: pointer; + padding: 16px; + color: #FEFEFE; + position: relative; + justify-content: space-between; + align-items: stretch; + background: rgba(15, 23, 53, 0.85); + + &[full-opacity] { + background: rgba(15, 23, 53, 1.0); + } +} + +.left { + display: flex; + align-items: center; +} + +.show-info { + padding-left: 12px; + border-left: 1px solid rgba(255, 255, 255, 0.4); +} + +.details { + margin-top: 4px; + color: #717DAD; + font-size: 14px; + font-weight: 500; +} + +.presentation-info { + margin-top: 4px; +} + +.presentation-method { + display: inline-block; + background: #1D326B; + color: #FEFEFE; + font-size: 12px; + font-weight: bold; + padding: 2px 10px; + margin-right: 8px; + border-radius: 10px; +} + +.show-time { + padding-right: 12px; + + .start { + text-align: center; + font-size: 18px; + font-weight: 100; + } + + .end { + text-align: center; + margin-top: 4px; + color: #717DAD; + font-size: 14px; + font-weight: 500; + } +} + +.title { + font-size: 14px; + font-weight: 500; +} + +.buy-tickets-button { + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 16px; +} + +@include screen-size-tablet { + :host { + margin-top: 15px; + padding: 20px 30px; + } + + .show-info { + padding-left: 25px; + } + + .details { + display: flex; + margin-top: 8px; + font-size: 20px; + font-weight: 300; + } + + .presentation-info { + margin-top: -1px; + margin-left: 12px; + } + + .presentation-method { + font-size: 15px; + font-weight: 600; + } + + .show-time { + padding-right: 25px; + + .start { + font-size: 30px; + line-height: 35px; + letter-spacing: 2px; + } + + .end { + margin-top: 8px; + font-size: 20px; + font-weight: 500; + line-height: 24px; + } + } + + .title { + font-size: 30px; + font-weight: 100; + line-height: 35px; + letter-spacing: 2px; + } + + .buy-tickets-button svg { + width: 32px; + height: 32px; + } +} diff --git a/web/lib/src/common/theater_selector/theater_dropdown_controller.dart b/web/lib/src/common/theater_selector/theater_dropdown_controller.dart new file mode 100644 index 00000000..9724eddc --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_dropdown_controller.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:meta/meta.dart'; + +import 'theater_selector_dropdown_menu_component.template.dart' as dropdown; + +class TheaterDropdownController { + static const animationDuration = Duration(milliseconds: 250); + + TheaterDropdownController._(this._menu); + ComponentRef _menu; + + bool get isDestroyed => _menu == null; + bool visible = false; + + static Future loadAndShow( + ComponentLoader loader, + ViewContainerRef container, { + String background = 'rgba(26, 26, 26, 0.9)', + }) async { + final menu = await loader.loadNextToLocation( + dropdown.TheaterSelectorDropdownMenuComponentNgFactory, + container, + ); + + final controller = TheaterDropdownController._(menu); + menu.instance + ..controller = controller + ..background = background; + + return controller + ..visible = true + .._menuAnimation(visible: true); + } + + void hideAndDestroy() { + visible = false; + _menuAnimation( + visible: false, + afterAnimation: () { + _menu.destroy(); + _menu = null; + }, + ); + } + + void _menuAnimation({@required bool visible, void afterAnimation()}) { + Timer( + const Duration(milliseconds: 25), + () => _menu?.instance?.isOpen = visible, + ); + + if (afterAnimation != null) { + Timer(animationDuration, afterAnimation); + } + } +} diff --git a/web/lib/src/common/theater_selector/theater_selector_component.dart b/web/lib/src/common/theater_selector/theater_selector_component.dart new file mode 100644 index 00000000..bfb68c76 --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_component.dart @@ -0,0 +1,41 @@ +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/common/theater_selector/theater_dropdown_controller.dart'; + +@Component( + selector: 'theater-selector', + styleUrls: ['theater_selector_component.css'], + templateUrl: 'theater_selector_component.html', +) +class TheaterSelectorComponent { + TheaterSelectorComponent(this._store, this._loader); + final Store _store; + final ComponentLoader _loader; + + TheaterListViewModel get _viewModel => TheaterListViewModel.fromStore(_store); + Theater get currentTheater => _viewModel.currentTheater; + + @ViewChild('menuContainer', read: ViewContainerRef) + ViewContainerRef menuContainer; + + TheaterDropdownController _menuController; + bool get theaterDropdownVisible => + _menuController != null && _menuController.isDestroyed == false; + + void toggleMenu() async { + if (!theaterDropdownVisible) { + _menuController = await TheaterDropdownController.loadAndShow( + _loader, + menuContainer, + ); + } else { + hideMenu(); + } + } + + void hideMenu() { + _menuController.hideAndDestroy(); + _menuController = null; + } +} diff --git a/web/lib/src/common/theater_selector/theater_selector_component.html b/web/lib/src/common/theater_selector/theater_selector_component.html new file mode 100644 index 00000000..071a8e82 --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_component.html @@ -0,0 +1,9 @@ +
+ A map pin. + {{ currentTheater.name }} + Drop down arrow. +
+ + \ No newline at end of file diff --git a/web/lib/src/common/theater_selector/theater_selector_component.scss b/web/lib/src/common/theater_selector/theater_selector_component.scss new file mode 100644 index 00000000..37db9454 --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_component.scss @@ -0,0 +1,50 @@ +@import '../../breakpoints'; + +:host { + position: relative; + z-index: 2001; +} + +.button { + position: relative; + width: 100%; + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid #717DAD; + border-radius: 5px; + padding: 6px; + user-select: none; + cursor: pointer; + + img { + width: 24px; + height: 24px; + } + + .button-text { + flex-grow: 1; + font-size: 16px; + padding: 0 8px; + color: #FEFEFE; + } +} + +.menu { + display: none; + position: absolute; + top: 36px; + width: 100%; + height: 400px; + + &.visible { + display: block; + } +} + +@include screen-size-phablet { + :host { + min-width: 250px; + } +} \ No newline at end of file diff --git a/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.dart b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.dart new file mode 100644 index 00000000..f8125943 --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.dart @@ -0,0 +1,32 @@ +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/common/theater_selector/theater_dropdown_controller.dart'; + +@Component( + selector: 'theater-selector-dropdown-menu', + templateUrl: 'theater_selector_dropdown_menu_component.html', + styleUrls: ['theater_selector_dropdown_menu_component.css'], + directives: [NgFor], +) +class TheaterSelectorDropdownMenuComponent { + TheaterSelectorDropdownMenuComponent(this._store); + final Store _store; + + TheaterDropdownController controller; + String background; + + TheaterListViewModel get _viewModel => TheaterListViewModel.fromStore(_store); + Theater get selectedTheater => _viewModel.currentTheater; + List get theaters => _viewModel.theaters; + + bool get focusTrapVisible => isOpen; + bool isOpen = false; + + void onTheaterClicked(Theater newTheater) { + _viewModel.changeCurrentTheater(newTheater); + controller.hideAndDestroy(); + } + + void hideAndDestroy() => controller.hideAndDestroy(); +} diff --git a/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.html b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.html new file mode 100644 index 00000000..c7bda14f --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.html @@ -0,0 +1,13 @@ +
+
+ + diff --git a/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.scss b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.scss new file mode 100644 index 00000000..a6a4742c --- /dev/null +++ b/web/lib/src/common/theater_selector/theater_selector_dropdown_menu_component.scss @@ -0,0 +1,50 @@ +.focus-trap { + display: none; + z-index: 2000; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + + &.visible { + display: block; + } +} + +.menu { + z-index: 2001; + position: relative; + width: 100%; + opacity: 0; + height: 0; + background: transparent; + overflow-y: scroll; + -webkit-overflow-scrolling: touch; + transition: height 250ms ease, opacity 250ms ease; + + &.opened { + opacity: 1; + height: 100%; + } +} + +.item { + position: relative; + cursor: pointer; + padding: 16px; + background: transparent; + font-size: 16px; + color: rgba(255, 255, 255, 0.56); + user-select: none; + + &.selected { + background: rgba(0, 0, 0, 0.54); + color: #ffffff; + } + + &:hover { + background: rgba(0, 0, 0, 0.2); + color: rgba(255, 255, 255, 0.8); + } +} \ No newline at end of file diff --git a/web/lib/src/event_details/actor_scroller/actor_image_component.dart b/web/lib/src/event_details/actor_scroller/actor_image_component.dart new file mode 100644 index 00000000..bbdcfb1f --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_image_component.dart @@ -0,0 +1,22 @@ +import 'dart:html'; + +import 'package:angular/angular.dart'; + +@Component( + selector: 'actor-img', + templateUrl: 'actor_image_component.html', + styleUrls: ['actor_image_component.css'], +) +class ActorImageComponent implements OnInit { + @Input() + String src; + + @ViewChild('actualImage') + ImageElement imageElement; + + @override + void ngOnInit() { + imageElement.addEventListener( + 'load', (_) => imageElement.classes.add('loaded')); + } +} diff --git a/web/lib/src/event_details/actor_scroller/actor_image_component.html b/web/lib/src/event_details/actor_scroller/actor_image_component.html new file mode 100644 index 00000000..2fab33d5 --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_image_component.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/web/lib/src/event_details/actor_scroller/actor_image_component.scss b/web/lib/src/event_details/actor_scroller/actor_image_component.scss new file mode 100644 index 00000000..808bfb58 --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_image_component.scss @@ -0,0 +1,33 @@ +.container { + position: relative; + background: #1C306D; + border-radius: 50%; + width: 60px; + height: 60px; +} + +img { + position: absolute; + border-radius: 50%; + object-fit: cover; + + &.placeholder { + z-index: 1; + width: 32px; + height: 32px; + top: 13px; + left: 13px; + } + + &.actual { + z-index: 2; + width: 60px; + height: 60px; + opacity: 0; + transition: opacity 250ms ease; + + &.loaded { + opacity: 1; + } + } +} diff --git a/web/lib/src/event_details/actor_scroller/actor_scroller_component.dart b/web/lib/src/event_details/actor_scroller/actor_scroller_component.dart new file mode 100644 index 00000000..0e7b5b05 --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_scroller_component.dart @@ -0,0 +1,14 @@ +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; +import 'package:web/src/event_details/actor_scroller/actor_image_component.dart'; + +@Component( + selector: 'actor-scroller', + templateUrl: 'actor_scroller_component.html', + styleUrls: ['actor_scroller_component.css'], + directives: [ActorImageComponent, NgFor], +) +class ActorScrollerComponent { + @Input() + List actors; +} diff --git a/web/lib/src/event_details/actor_scroller/actor_scroller_component.html b/web/lib/src/event_details/actor_scroller/actor_scroller_component.html new file mode 100644 index 00000000..08e62aff --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_scroller_component.html @@ -0,0 +1,4 @@ +
+ +

{{ actor.name }}

+
diff --git a/web/lib/src/event_details/actor_scroller/actor_scroller_component.scss b/web/lib/src/event_details/actor_scroller/actor_scroller_component.scss new file mode 100644 index 00000000..53ed5e86 --- /dev/null +++ b/web/lib/src/event_details/actor_scroller/actor_scroller_component.scss @@ -0,0 +1,29 @@ +@import '../../breakpoints'; + +:host { + display: flex; + overflow-x: scroll; + -webkit-overflow-scrolling: touch; + padding-left: 20px; + + @include screen-size-laptop { + padding-left: 0; + } + + div { + margin-right: 20px; + max-width: 60px; + + &:last-child { + margin-right: 0; + } + + p { + color: #1D1D1B; + font-size: 12px; + margin-top: 10px; + line-height: 14px; + text-align: center; + } + } +} diff --git a/web/lib/src/event_details/event_details_component.dart b/web/lib/src/event_details/event_details_component.dart new file mode 100644 index 00000000..9efe323a --- /dev/null +++ b/web/lib/src/event_details/event_details_component.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:html' as html; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/common/content_rating/content_rating_component.dart'; +import 'package:web/src/common/event_poster/event_poster_component.dart'; +import 'package:web/src/common/showtime_item/showtime_item_component.dart'; +import 'package:web/src/event_details/actor_scroller/actor_scroller_component.dart'; +import 'package:web/src/event_details/landscape_image/event_landscape_image_component.dart'; +import 'package:web/src/routes.dart'; + +@Component( + selector: 'event-details', + styleUrls: ['event_details_component.css'], + templateUrl: 'event_details_component.html', + directives: [ + EventLandscapeImageComponent, + ActorScrollerComponent, + EventPosterComponent, + ShowtimeItemComponent, + ContentRatingComponent, + NgIf, + NgFor, + ], + pipes: [DatePipe], +) +class EventDetailsComponent implements OnInit, OnActivate, OnDestroy { + EventDetailsComponent(this._store, this._router, this.messages); + final Store _store; + final Router _router; + final Messages messages; + + Event event; + Show show; + bool _navigatedFromApp = false; + bool contentVisible = false; + StreamSubscription _eventDetailsSubscription; + + @override + void ngOnInit() { + // Reset the scroll position in case this page was previously opened. + html.window.scrollTo(0, 0); + } + + @override + void onActivate(RouterState previous, RouterState current) { + _navigatedFromApp = previous != null; + + _populateEventDetails( + current.parameters['eventId'], + current.parameters['showId'], + ); + } + + @override + void ngOnDestroy() => _eventDetailsSubscription?.cancel(); + + void _populateEventDetails(String eventId, String showId) { + event = eventByIdSelector(_store.state, eventId); + show = showByIdSelector(_store.state, showId); + + if (show != null) { + // If event is still null, try to find it by show. + event ??= eventForShowSelector(_store.state, show); + } + + if (event != null) { + _store.dispatch(FetchActorAvatarsAction(event)); + _animateContentIntoView(); + } else { + _store.dispatch(RefreshEventsAction(EventListType.nowInTheaters)); + _store.dispatch(RefreshEventsAction(EventListType.comingSoon)); + _waitForEventDetails(eventId, showId); + } + } + + /// The event details page was opened before loading data has finished. + /// + /// This happened because the user came to event details page by a link, + /// for example [https://inkino.app/#event/302789]. + /// + /// Since in this case, the event details page is the first entry point for + /// inKino, we'll have to wait until the store is populated with all the events. + void _waitForEventDetails(String eventId, String showId) { + final state = _store.state.eventState; + final isLoading = state.nowInTheatersStatus == LoadingStatus.loading || + state.comingSoonStatus == LoadingStatus.loading; + + if (!isLoading) { + return; + } + + _eventDetailsSubscription = _store.onChange.listen((state) { + final state = _store.state.eventState; + final hasFinishedLoading = + state.nowInTheatersStatus != LoadingStatus.loading && + state.comingSoonStatus != LoadingStatus.loading; + + if (hasFinishedLoading) { + _populateEventDetails(eventId, showId); + _eventDetailsSubscription.cancel(); + _eventDetailsSubscription = null; + + _animateContentIntoView(); + } + }); + } + + void _animateContentIntoView() => + Timer(Duration.zero, () => contentVisible = true); + + void openShow() => html.window.open(show.url, 'Tickets for ${show.title}'); + + void goBack() { + if (_navigatedFromApp) { + html.window.history.back(); + return; + } + + _router.navigateByUrl( + RoutePaths.nowInTheaters.toUrl(), + replace: true, + ); + } +} diff --git a/web/lib/src/event_details/event_details_component.html b/web/lib/src/event_details/event_details_component.html new file mode 100644 index 00000000..9bf335f2 --- /dev/null +++ b/web/lib/src/event_details/event_details_component.html @@ -0,0 +1,49 @@ +
+ + + +
+ +
+

{{ event.title }}

+ +
+

{{ event.lengthInMinutes }} min

+

+ {{ messages.director }}: {{ event.director }} +

+
+ + +
+
+ +
+ +
+ +
+
+

{{ messages.storyline }}

+

{{ event.synopsis }}

+
+
+ +
+
+

{{ messages.cast }}

+ +
+
+ + +
diff --git a/web/lib/src/event_details/event_details_component.scss b/web/lib/src/event_details/event_details_component.scss new file mode 100644 index 00000000..c15b65bf --- /dev/null +++ b/web/lib/src/event_details/event_details_component.scss @@ -0,0 +1,231 @@ +@import '../common'; +@import '../breakpoints'; + +.container { + @include full-size-overlay; + + bottom: unset; + background: #f0f0f0; + min-height: 100%; + max-width: 100%; + opacity: 0; + transition: opacity 500ms ease; + + &.visible { + opacity: 1; + } +} + +.back { + position: absolute; + top: 0; + left: 0; + cursor: pointer; + z-index: 3; + width: 68px; + height: 68px; + padding: 20px; +} + +.event-header { + display: flex; + flex-flow: row; + align-items: flex-start; + margin: -50px 20px 0 20px; + position: relative; + z-index: 3; +} + +event-poster { + flex: 1; + min-width: 125px; + max-width: 200px; + box-shadow: 0 4px 20px 2px rgba(0, 0, 0, 0.35); +} + +.event-information { + flex: 2; + margin: 60px 0 0 15px; + + .title { + color: #1D1D1B; + font-size: 18px; + font-weight: bold; + line-height: 1.3; + } + + .body { + font-size: 14px; + margin-top: 10px; + + .length { + margin-bottom: 5px; + } + + .director { + margin-bottom: 10px; + } + } +} + +.centered-content { + margin-left: 20px; + margin-right: 20px; +} + +.actor-section .centered-content { + h3 { + margin: 0 20px 8px 20px; + } + + margin: 0; +} + +.section { + margin-top: 10px; + padding: 20px 0 15px 0; + + &.top-shadow { + box-shadow: 0px -2px 30px rgba(0, 0, 0, 0.1) + } + + &.white { + background: #ffffff; + } + + &.footer { + background: url("images/background-image.jpg") no-repeat bottom fixed; + background-size: cover; + margin-top: 0; + padding-bottom: 80px; + } + + h3 { + color: #1D1D1B; + font-size: 18px; + font-weight: 500; + margin-bottom: 6px; + text-transform: uppercase; + } + + p { + color: #1D1D1B; + font-size: 14px; + line-height: 1.5; + } +} + +.footer { + background: linear-gradient(#1C306D, #141e56) no-repeat fixed; + + h3 { + color: #FEFEFE; + } +} + +.gallery { + margin-top: 20px; + + img { + margin-right: 30px; + margin-bottom: 30px; + width: calc(100% / 2 - 20px); + min-height: 90px; + box-shadow: 2px 2px 10px 4px rgba(0, 0, 0, 0.35); + } +} + +.gallery img:nth-child(2n) { + margin-right: 0; +} + +.synopsis { + white-space: pre-wrap; +} + +@include screen-size-nexus-5x { + .gallery img { + min-height: 124px; + } +} + +@include screen-size-tablet { + .event-information { + margin-left: 25px; + + .title { + font-size: 30px; + line-height: 1.5; + } + + .body { + font-size: 16px; + } + } + + .gallery img { + min-height: 258px; + } +} + +@include screen-size-laptop { + .event-header { + margin: -225px auto 0 auto; + width: 70%; + } + + event-poster { + flex: unset; + min-width: unset; + max-width: unset; + width: 300px; + height: 450px; + } + + .event-information { + margin-top: 240px; + margin-left: 30px; + } + + .centered-content { + width: 70%; + margin-left: auto; + margin-right: auto; + } + + .actor-section .centered-content { + h3 { + margin-left: 0; + margin-right: 0; + } + + margin-left: auto; + margin-right: auto; + } + + .section { + margin-top: 30px; + padding: 30px 0 40px 0; + + h3 { + font-size: 30px; + } + + p { + font-size: 16px; + } + } + + .gallery img { + width: calc(100% / 3 - 20px); + min-height: 164px; + + &:nth-child(2n) { + margin-right: 30px; + } + + &:nth-child(3n) { + margin-right: 0; + } + } +} \ No newline at end of file diff --git a/web/lib/src/event_details/landscape_image/event_landscape_image_component.dart b/web/lib/src/event_details/landscape_image/event_landscape_image_component.dart new file mode 100644 index 00000000..5cae26a4 --- /dev/null +++ b/web/lib/src/event_details/landscape_image/event_landscape_image_component.dart @@ -0,0 +1,48 @@ +import 'dart:html' as html; + +import 'package:angular/angular.dart'; +import 'package:core/core.dart'; + +@Component( + selector: 'event-landscape-image', + templateUrl: 'event_landscape_image_component.html', + styleUrls: ['event_landscape_image_component.css'], +) +class EventLandscapeImageComponent implements OnInit, OnDestroy { + @Input() + Event event; + + @ViewChild('actualImage') + html.ImageElement imageElement; + + bool _triedWithSecondLandscapeUrl = false; + + @override + void ngOnInit() { + imageElement.addEventListener('load', _onLoad); + imageElement.addEventListener('error', _onError); + } + + @override + void ngOnDestroy() => _clearListeners(); + + void _onLoad(html.Event _) { + imageElement.classes.add('loaded'); + _clearListeners(); + } + + void _onError(html.Event _) { + if (_triedWithSecondLandscapeUrl) { + _clearListeners(); + return; + } + + imageElement.src = event.images.landscapeHd2; + _triedWithSecondLandscapeUrl = true; + } + + void _clearListeners() { + imageElement.removeEventListener('load', _onLoad); + imageElement.removeEventListener('error', _onError); + } +} diff --git a/web/lib/src/event_details/landscape_image/event_landscape_image_component.html b/web/lib/src/event_details/landscape_image/event_landscape_image_component.html new file mode 100644 index 00000000..dd7cf343 --- /dev/null +++ b/web/lib/src/event_details/landscape_image/event_landscape_image_component.html @@ -0,0 +1,6 @@ +
+
+ +
+ +
diff --git a/web/lib/src/event_details/landscape_image/event_landscape_image_component.scss b/web/lib/src/event_details/landscape_image/event_landscape_image_component.scss new file mode 100644 index 00000000..61185dcb --- /dev/null +++ b/web/lib/src/event_details/landscape_image/event_landscape_image_component.scss @@ -0,0 +1,43 @@ +@import '../../breakpoints'; + +.container { + position: relative; + width: 100%; + height: 225px; + background: linear-gradient(to top, #222222, #424242); +} + +.placeholder { + position: absolute; + width: 100%; + height: 225px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + + img { + width: 128px; + height: 128px; + } +} + +.actual { + position: absolute; + object-fit: cover; + z-index: 2; + width: 100%; + height: 225px; + opacity: 0; + transition: opacity 750ms ease; + + &.loaded { + opacity: 1; + } +} + +@include screen-size-laptop { + .container, .placeholder, .actual { + height: 450px; + } +} diff --git a/web/lib/src/events/events_page_component.dart b/web/lib/src/events/events_page_component.dart new file mode 100644 index 00000000..3c9ea455 --- /dev/null +++ b/web/lib/src/events/events_page_component.dart @@ -0,0 +1,58 @@ +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/common/event_poster/event_poster_component.dart'; +import 'package:web/src/common/loading_view/loading_view_component.dart'; +import 'package:web/src/common/theater_selector/theater_selector_component.dart'; +import 'package:web/src/routes.dart'; + +import '../restore_scroll_position.dart'; + +@Component( + selector: 'events-page', + styleUrls: ['events_page_component.css'], + templateUrl: 'events_page_component.html', + directives: [ + TheaterSelectorComponent, + LoadingViewComponent, + EventPosterComponent, + NgFor, + ], +) +class EventsPageComponent implements OnActivate { + EventsPageComponent(this._store, this._router, this.messages); + final Store _store; + final Router _router; + final Messages messages; + + EventListType _listType; + + EventsPageViewModel get viewModel => + EventsPageViewModel.fromStore(_store, _listType); + + String get eventTypeTitle => _listType == EventListType.nowInTheaters + ? messages.nowInTheaters + : messages.comingSoon; + + bool get isDisplayingComingSoonMovies => + _listType == EventListType.comingSoon; + + @override + void onActivate(RouterState previous, RouterState current) { + _listType = current.routePath.additionalData; + restoreScrollPositionIfNeeded(previous, RoutePaths.eventDetails); + + if (_listType == EventListType.comingSoon) { + _store.dispatch(FetchComingSoonEventsIfNotLoadedAction()); + } + } + + void openEventDetails(Event event) { + storeCurrentScrollPosition(); + + final url = + RoutePaths.eventDetails.toUrl(parameters: {'eventId': event.id}); + _router.navigate(url); + } +} diff --git a/web/lib/src/events/events_page_component.html b/web/lib/src/events/events_page_component.html new file mode 100644 index 00000000..e25b5a76 --- /dev/null +++ b/web/lib/src/events/events_page_component.html @@ -0,0 +1,19 @@ +
+
+

{{ eventTypeTitle }}

+ +
+ + +
+ + +
+
+
diff --git a/web/lib/src/events/events_page_component.scss b/web/lib/src/events/events_page_component.scss new file mode 100644 index 00000000..99119d15 --- /dev/null +++ b/web/lib/src/events/events_page_component.scss @@ -0,0 +1,73 @@ +@import '../breakpoints'; +@import '../common'; + +.grid-container { + display: flex; + flex-flow: row wrap; +} + +event-poster { + width: calc(100% / 2); + min-height: 243px; + cursor: pointer; +} + +@include screen-size-nexus-5x { + event-poster { + min-height: 312px; + } +} + +@include screen-size-phablet { + event-poster { + width: calc(100% / 3); + min-height: 327px; + } +} + +$horizontal-margin: 1.5em; +$vertical-margin: 1.5em; + +@include screen-size-tablet { + event-poster { + box-shadow: 1px 1px 8px 2px rgba(0, 0, 0, 0.32); + margin-top: $vertical-margin; + margin-right: $horizontal-margin; + width: calc(100% / 3 - 1em); + min-height: 343px; + } + + .grid-container :nth-child(3n) { + margin-right: 0; + } +} + +@include screen-size-laptop { + event-poster { + width: calc(100% / 3 - 1em); + min-height: 404px; + } + + .grid-container { + margin: 0; + } + + .grid-container :nth-child(3n) { + margin-right: 0; + } +} + +@include screen-size-huge { + event-poster { + width: calc(100% / 4 - 1.15em); + min-height: 448px; + } + + .grid-container :nth-child(3n) { + margin-right: $horizontal-margin; + } + + .grid-container :nth-child(4n) { + margin-right: 0; + } +} diff --git a/web/lib/src/restore_scroll_position.dart b/web/lib/src/restore_scroll_position.dart new file mode 100644 index 00000000..12c2b5a4 --- /dev/null +++ b/web/lib/src/restore_scroll_position.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'dart:html'; + +import 'package:angular_router/angular_router.dart'; + +void storeCurrentScrollPosition() => + window.sessionStorage['scrollY'] = window.scrollY.toString(); + +void restoreScrollPositionIfNeeded( + RouterState previous, RoutePath restoreWhenComingFrom) { + final shouldRestoreScrollPosition = + previous?.routePath?.path == restoreWhenComingFrom.path; + + if (shouldRestoreScrollPosition) { + Timer(Duration.zero, () { + window.scrollTo(0, int.tryParse(window.sessionStorage['scrollY'] ?? '0')); + }); + } else { + window.scrollTo(0, 0); + } +} diff --git a/web/lib/src/routes.dart b/web/lib/src/routes.dart new file mode 100644 index 00000000..e0d2f2bb --- /dev/null +++ b/web/lib/src/routes.dart @@ -0,0 +1,63 @@ +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:web/src/event_details/event_details_component.template.dart' + deferred as event_details; +import 'package:web/src/events/events_page_component.template.dart' + as events_page; +import 'package:web/src/showtimes/showtimes_page_component.template.dart' + deferred as showtimes_page; + +class RoutePaths { + static final nowInTheaters = RoutePath( + path: '/', + additionalData: EventListType.nowInTheaters, + useAsDefault: true, + ); + + static final showtimes = RoutePath(path: 'showtimes'); + static final comingSoon = RoutePath( + path: 'comingSoon', + additionalData: EventListType.comingSoon, + ); + + static final eventDetails = RoutePath(path: 'event/:eventId'); + static final showDetails = RoutePath(path: 'show/:eventId/:showId'); +} + +class Routes { + static final List all = [ + RouteDefinition( + routePath: RoutePaths.nowInTheaters, + useAsDefault: true, + component: events_page.EventsPageComponentNgFactory, + ), + RouteDefinition( + routePath: RoutePaths.comingSoon, + component: events_page.EventsPageComponentNgFactory, + ), + RouteDefinition.defer( + routePath: RoutePaths.showtimes, + loader: () { + return showtimes_page + .loadLibrary() + .then((_) => showtimes_page.ShowtimesPageComponentNgFactory); + }, + ), + RouteDefinition.defer( + routePath: RoutePaths.eventDetails, + loader: () { + return event_details + .loadLibrary() + .then((_) => event_details.EventDetailsComponentNgFactory); + }, + ), + RouteDefinition.defer( + routePath: RoutePaths.showDetails, + loader: () { + return event_details + .loadLibrary() + .then((_) => event_details.EventDetailsComponentNgFactory); + }, + ), + ]; +} diff --git a/web/lib/src/showtimes/date_selector_component.dart b/web/lib/src/showtimes/date_selector_component.dart new file mode 100644 index 00000000..c7d057d5 --- /dev/null +++ b/web/lib/src/showtimes/date_selector_component.dart @@ -0,0 +1,19 @@ +import 'package:angular/angular.dart'; + +@Component( + selector: 'date-selector', + styleUrls: ['date_selector_component.css'], + templateUrl: 'date_selector_component.html', + directives: [NgFor], + pipes: [DatePipe], +) +class DateSelectorComponent { + @Input() + List dates; + + @Input() + DateTime selectedDate; + + @Input() + Function(DateTime) newDateSelected; +} diff --git a/web/lib/src/showtimes/date_selector_component.html b/web/lib/src/showtimes/date_selector_component.html new file mode 100644 index 00000000..f0fd16ba --- /dev/null +++ b/web/lib/src/showtimes/date_selector_component.html @@ -0,0 +1,7 @@ +
+

{{ date | date: 'E' }}

+

{{ date | date: 'd' }}

+
diff --git a/web/lib/src/showtimes/date_selector_component.scss b/web/lib/src/showtimes/date_selector_component.scss new file mode 100644 index 00000000..14d75538 --- /dev/null +++ b/web/lib/src/showtimes/date_selector_component.scss @@ -0,0 +1,64 @@ +@import '../breakpoints'; + +:host { + display: flex; + color: rgba(255, 255, 255, 0.4); + justify-content: space-around; +} + +.date-item { + flex: 1; + cursor: pointer; + height: 50px; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + background: #0F1734; + transition: background-color 0.2s ease; + + p { + text-align: center; + } + + .dayname { + color: #717DAD; + font-size: 12px; + font-weight: 500; + transition: color 0.2s ease; + } + + .day { + color: #ffffff; + font-size: 20px; + font-weight: 300; + transition: color 0.2s ease, font-weight 0.2s ease; + } +} + +.selected { + background: #FBBE35; + + .dayname, .day { + color: #0d1732; + } + + .day { + font-weight: 500; + } +} + +@include screen-size-laptop { + .date-item { + height: 60px; + margin-right: 3px; + + &:last-child { + margin-right: 0; + } + + .day { + font-size: 25px; + } + } +} \ No newline at end of file diff --git a/web/lib/src/showtimes/showtimes_page_component.dart b/web/lib/src/showtimes/showtimes_page_component.dart new file mode 100644 index 00000000..c00febdb --- /dev/null +++ b/web/lib/src/showtimes/showtimes_page_component.dart @@ -0,0 +1,59 @@ +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:redux/redux.dart'; +import 'package:web/src/common/loading_view/loading_view_component.dart'; +import 'package:web/src/common/showtime_item/showtime_item_component.dart'; +import 'package:web/src/common/theater_selector/theater_selector_component.dart'; +import 'package:web/src/restore_scroll_position.dart'; +import 'package:web/src/routes.dart'; +import 'package:web/src/showtimes/date_selector_component.dart'; + +@Component( + selector: 'showtimes-page', + styleUrls: ['showtimes_page_component.css'], + templateUrl: 'showtimes_page_component.html', + directives: [ + TheaterSelectorComponent, + LoadingViewComponent, + ShowtimeItemComponent, + DateSelectorComponent, + NgFor, + NgIf, + ], + pipes: [DatePipe], +) +class ShowtimesPageComponent implements OnActivate { + ShowtimesPageComponent(this._store, this._router, this.messages); + final Store _store; + final Router _router; + final Messages messages; + + @Input('event-filter') + Event eventFilter; + + ShowtimesPageViewModel get viewModel => + ShowtimesPageViewModel.fromStore(_store); + + List get shows => eventFilter == null + ? viewModel.shows + : showsForEventSelector(viewModel.shows, eventFilter); + + void openShowDetails(Show show) { + storeCurrentScrollPosition(); + + final event = eventForShowSelector(_store.state, show); + final url = RoutePaths.showDetails.toUrl(parameters: { + 'eventId': event.id, + 'showId': show.id, + }); + + _router.navigate(url); + } + + @override + void onActivate(RouterState previous, _) { + restoreScrollPositionIfNeeded(previous, RoutePaths.showDetails); + _store.dispatch(FetchShowsIfNotLoadedAction()); + } +} diff --git a/web/lib/src/showtimes/showtimes_page_component.html b/web/lib/src/showtimes/showtimes_page_component.html new file mode 100644 index 00000000..349f6cc7 --- /dev/null +++ b/web/lib/src/showtimes/showtimes_page_component.html @@ -0,0 +1,19 @@ +
+
+

{{ messages.showtimes }}

+ +
+ + + + + + +
diff --git a/web/lib/src/showtimes/showtimes_page_component.scss b/web/lib/src/showtimes/showtimes_page_component.scss new file mode 100644 index 00000000..443c1003 --- /dev/null +++ b/web/lib/src/showtimes/showtimes_page_component.scss @@ -0,0 +1,14 @@ +@import '../common'; +@import '../breakpoints'; + +.page-header { + display: flex; + + theater-selector { + margin-left: 30px; + } +} + +.page-title { + margin-bottom: 30px; +} diff --git a/web/pubspec.yaml b/web/pubspec.yaml new file mode 100644 index 00000000..7a263480 --- /dev/null +++ b/web/pubspec.yaml @@ -0,0 +1,24 @@ +name: web +description: A web app that uses AngularDart Components +# version: 1.0.0 +# homepage: https://www.example.com +# author: ironman + +environment: + sdk: '>=2.0.0 <3.0.0' + +dependencies: + core: + path: ../core + angular: ^5.0.0 + angular_router: ^2.0.0-alpha+19 + key_value_store_web: ^1.0.0 + pwa: ^0.1.11 + +dev_dependencies: + angular_test: ^2.0.0 + build_runner: ^0.10.0 + build_test: ^0.10.3 + build_web_compilers: ^0.4.1 + sass_builder: ^2.1.1 + test: ^1.3.0 diff --git a/web/web/images/arrow_drop_down.svg b/web/web/images/arrow_drop_down.svg new file mode 100644 index 00000000..bf498705 --- /dev/null +++ b/web/web/images/arrow_drop_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/web/images/back.svg b/web/web/images/back.svg new file mode 100644 index 00000000..c9eab0be --- /dev/null +++ b/web/web/images/back.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/web/images/background-image.jpg b/web/web/images/background-image.jpg new file mode 100644 index 00000000..4fef9a0a Binary files /dev/null and b/web/web/images/background-image.jpg differ diff --git a/web/web/images/close.svg b/web/web/images/close.svg new file mode 100644 index 00000000..85439bab --- /dev/null +++ b/web/web/images/close.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/web/web/images/coming-soon.svg b/web/web/images/coming-soon.svg new file mode 100644 index 00000000..fda61443 --- /dev/null +++ b/web/web/images/coming-soon.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/web/web/images/fallback-icon.svg b/web/web/images/fallback-icon.svg new file mode 100644 index 00000000..f1923d86 --- /dev/null +++ b/web/web/images/fallback-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/web/images/favicon.png b/web/web/images/favicon.png new file mode 100644 index 00000000..ae0b64b8 Binary files /dev/null and b/web/web/images/favicon.png differ diff --git a/web/web/images/icon-192.png b/web/web/images/icon-192.png new file mode 100644 index 00000000..da798912 Binary files /dev/null and b/web/web/images/icon-192.png differ diff --git a/web/web/images/icon-48.png b/web/web/images/icon-48.png new file mode 100644 index 00000000..6635e743 Binary files /dev/null and b/web/web/images/icon-48.png differ diff --git a/web/web/images/icon-512.png b/web/web/images/icon-512.png new file mode 100644 index 00000000..ea2a9bd5 Binary files /dev/null and b/web/web/images/icon-512.png differ diff --git a/web/web/images/icon-96.png b/web/web/images/icon-96.png new file mode 100644 index 00000000..d7c7c492 Binary files /dev/null and b/web/web/images/icon-96.png differ diff --git a/web/web/images/info.svg b/web/web/images/info.svg new file mode 100644 index 00000000..35751348 --- /dev/null +++ b/web/web/images/info.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/web/images/logo.png b/web/web/images/logo.png new file mode 100644 index 00000000..8cca4f19 Binary files /dev/null and b/web/web/images/logo.png differ diff --git a/web/web/images/now-in-theaters.svg b/web/web/images/now-in-theaters.svg new file mode 100644 index 00000000..041f9798 --- /dev/null +++ b/web/web/images/now-in-theaters.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/web/web/images/place.svg b/web/web/images/place.svg new file mode 100644 index 00000000..baf402bc --- /dev/null +++ b/web/web/images/place.svg @@ -0,0 +1 @@ + diff --git a/web/web/images/profile.svg b/web/web/images/profile.svg new file mode 100644 index 00000000..4ba68289 --- /dev/null +++ b/web/web/images/profile.svg @@ -0,0 +1,5 @@ + + + + diff --git a/web/web/images/search.svg b/web/web/images/search.svg new file mode 100644 index 00000000..40a3320c --- /dev/null +++ b/web/web/images/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/web/images/showtimes.svg b/web/web/images/showtimes.svg new file mode 100644 index 00000000..06259f21 --- /dev/null +++ b/web/web/images/showtimes.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/web/web/images/theaters.svg b/web/web/images/theaters.svg new file mode 100644 index 00000000..18d5cf32 --- /dev/null +++ b/web/web/images/theaters.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/web/web/index.html b/web/web/index.html new file mode 100644 index 00000000..35630c43 --- /dev/null +++ b/web/web/index.html @@ -0,0 +1,41 @@ + + + + inKino - browse the movie selection of Finnish Finnkino cinemas + + + + + + + + + + + + + + +
+ +
+ + diff --git a/web/web/main.dart b/web/web/main.dart new file mode 100644 index 00000000..a8bb45a6 --- /dev/null +++ b/web/web/main.dart @@ -0,0 +1,51 @@ +import 'dart:html'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:core/core.dart'; +import 'package:http/http.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/intl_browser.dart'; +import 'package:key_value_store_web/key_value_store_web.dart'; +import 'package:pwa/client.dart' as pwa; +import 'package:redux/redux.dart'; +import 'package:web/app_component.template.dart' as ng; + +import 'main.template.dart' as self; + +final Store _store = createStore( + Client(), + WebKeyValueStore(window.localStorage), +); +Store storeFactory() => _store; + +@GenerateInjector([ + const FactoryProvider(Store, storeFactory), + const ClassProvider(Messages), + routerProvidersHash, +]) +final InjectorFactory rootInjector = self.rootInjector$Injector; + +void main() async { + pwa.Client(); + await _initializeTranslations(); + + runApp(ng.AppComponentNgFactory, createInjector: rootInjector); +} + +void _initializeTranslations() async { + var locale = await findSystemLocale(); + final initializationSuccessful = await initializeMessages(locale); + await initializeDateFormatting(locale); + + if (!initializationSuccessful) { + // If we can't initialize messages for current locale, fall back on English. + locale = 'en'; + await initializeMessages(locale); + await initializeDateFormatting(locale); + } + + FinnkinoApi.useFinnish = locale == 'fi'; + Intl.defaultLocale = locale; +} diff --git a/web/web/manifest.json b/web/web/manifest.json new file mode 100644 index 00000000..9fd30180 --- /dev/null +++ b/web/web/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "inKino", + "short_name": "inKino", + "description": "A web app that uses AngularDart Components", + "lang": "en-US", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#1C306D", + "background_color": "#ffffff", + "icons": [ + { + "src": "images/icon-48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "images/icon-96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "images/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "images/icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "related_applications": [ + { + "platform": "play", + "url": "https://play.google.com/store/apps/details?id=com.roughike.inkino", + "id": "com.roughike.inkino" + }, { + "platform": "itunes", + "url": "https://itunes.apple.com/us/app/inkino/id1367181450" + }] +} diff --git a/web/web/privacy.html b/web/web/privacy.html new file mode 100644 index 00000000..0ed8cb04 --- /dev/null +++ b/web/web/privacy.html @@ -0,0 +1,12 @@ + + + + inKino - Privacy Policy + + +
+

inKino Privacy Policy

+

InKino does not store any of your data.

+
+ + diff --git a/web/web/pwa.dart b/web/web/pwa.dart new file mode 100644 index 00000000..35410a3a --- /dev/null +++ b/web/web/pwa.dart @@ -0,0 +1,39 @@ +import 'package:core/core.dart'; +import 'package:pwa/worker.dart'; + +void main() { + final cache = DynamicCache('inkino-cache', maxAge: const Duration(days: 1)); + + Worker() + ..offlineUrls = [ + './', + './main.dart.js', + './main.dart.js_1.part.js', + './main.dart.js_2.part.js', + './main.dart.js_3.part.js', + './main.dart.js_4.part.js', + './main.dart.js_5.part.js', + './main.dart.js_6.part.js', + './images/arrow_drop_down.svg', + './images/back.svg', + './images/background-image.jpg', + './images/close.svg', + './images/coming-soon.svg', + './images/fallback-icon.svg', + './images/favicon.png', + './images/info.svg', + './images/logo.png', + './images/now-in-theaters.svg', + './images/place.svg', + './images/profile.svg', + './images/search.svg', + './images/showtimes.svg', + './images/theaters.svg', + './manifest.json', + ] + ..router.registerGetUrl( + FinnkinoApi.enBaseUrl, (request) => cache.cacheFirst(request)) + ..router.registerGetUrl( + FinnkinoApi.fiBaseUrl, (request) => cache.cacheFirst(request)) + ..run(version: '6'); +} diff --git a/web/web/robots.txt b/web/web/robots.txt new file mode 100644 index 00000000..6f27bb66 --- /dev/null +++ b/web/web/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file