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.
+## 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
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800a.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800a.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800b.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800b.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800c.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800c.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800d.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800d.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800e.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800e.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800f.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800f.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800g.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800g.jpg
+
+
+
+ http://media.finnkino.fi/1012/Event_12007/gallery/Adrift_800h.jpg
+ http://media.finnkino.fi/1012/Event_12007/gallery/THUMB_Adrift_800h.jpg
+
+
Anthony
@@ -104,9 +146,9 @@
2017
107
2017-12-25T00:00:00
- 7
- 7
- https://media.finnkino.fi/images/rating_large_7.png
+ Not yet rated
+ Not yet rated
+ https://media.finnkino.fi/images/rating_large_Not yet rated.png
SF Film Finland Oy
SF Film Finland Oy
-
@@ -132,32 +174,7 @@
YouTubeVideo
-
-
- Jens
- Hulten
-
-
- Kari-Pekka
- Toivonen
-
-
- Jon-Jon
- Geitel
-
-
- Akseli
- Kouki
-
-
- Kustaa
- Tuohimaa
-
-
- Joel
- Hirvonen
-
-
+
test
@@ -250,4 +267,4 @@
-
\ No newline at end of file
+''';
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 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 @@
-
+const String showsXml = '''
2018-02-21T00:00:00+02:00
@@ -19,7 +19,7 @@
2018-02-21T07:00:00Z
302419
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.
+
+
+
+
+
+## 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