diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..0435740 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,10 @@ +codecov: + notify: + wait_for_ci: no +coverage: + status: + project: + default: + target: auto + threshold: 0% +comment: false diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1734b63 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,59 @@ +#!/bin/bash +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# Run flutter format, analyze and test +( + git stash --include-untracked --keep-index && + trap 'r=$?; git stash pop; exit $r' EXIT + git status + flutter format -n --set-exit-if-changed lib test || exit $? + flutter analyze || exit $? + flutter test || exit $? +) || exit $? + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..7f36070 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,60 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if echo "$local_ref" | grep -q '/local/' + then + echo "Found local ref name '$local_ref' has '/local/' in it." >&2 + echo "Not pushing refs with containing that as they are " >&2 + echo "supposed to be local only." >&2 + exit 1 + fi + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^\(WIP\|fixup!\|squash!\)' "$range"` + if [ -n "$commit" ] + then + echo "Found WIP commit in $local_ref, not pushing" >&2 + exit 1 + fi + fi +done + +exit 0 diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..ddf285c --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,30 @@ +name: automerge +on: + pull_request: + types: + - labeled + - unlabeled + - synchronize + - opened + - edited + - ready_for_review + - reopened + - unlocked + pull_request_review: + types: + - submitted + check_suite: + types: + - completed + status: {} +jobs: + automerge: + runs-on: ubuntu-latest + steps: + - name: automerge + uses: "pascalgn/automerge-action@v0.11.0" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + MERGE_DELETE_BRANCH: true + MERGE_REMOVE_LABELS: automerge + UPDATE_METHOD: rebase diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ae29dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + strategy: + fail-fast: false + matrix: + flutter_version: [ "1.20", "1.22" ] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-java@v1 + with: + java-version: '12.x' + + - uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ matrix.flutter_version }} + channel: stable + + - name: Install dependencies + run: flutter pub get + + # Analyze step needs different config for pull_request and push, so it is + # duplicated with if conditions to use the correct configuration for each + - name: Analyze (push) + if: ${{ github.event_name == 'push' }} + uses: kitek/dartanalyzer-annotations-action@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + check_name: test + commit_sha: ${{ github.sha }} + - name: Analyze (pull_request) + if: ${{ github.event_name == 'pull_request' }} + uses: kitek/dartanalyzer-annotations-action@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + check_name: test + commit_sha: ${{ github.event.pull_request.head.sha }} + + - name: Check format + run: flutter format -n --set-exit-if-changed lib test example + + - name: Run unit tests + run: flutter test --coverage + + - name: Upload unit tests coverage to Codecov + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: coverage/lcov.info + flags: unit,flutter${{ matrix.flutter_version }} + +# vim: set et sw=2 sts=2 : diff --git a/.github/workflows/pub-score.yml b/.github/workflows/pub-score.yml new file mode 100644 index 0000000..717f07f --- /dev/null +++ b/.github/workflows/pub-score.yml @@ -0,0 +1,32 @@ +name: Pub Score +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + pub-score: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: axel-op/dart-package-analyzer@v3 + id: score + with: + githubToken: ${{ github.token }} + - name: Check score + env: + expected_score: 100 + cur_points: ${{ steps.score.outputs.total }} + max_points: ${{ steps.score.outputs.total_max }} + run: | + score=$(( $cur_points * 100 / $max_points )) + if test $score -lt $expected_score + then + exec >&2 + echo "Pub package score is too low." + echo "$expected_score is expected, but we got $score :(" + exit 1 + fi + +# vim: set et sw=2 sts=2 : diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2d7395 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +coverage/ +example/pubspec.lock + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..9432b08 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f30b7f4db93ee747cd727df747941a28ead25ff5 + channel: stable + +project_type: app diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b78d64c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..53d1f3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..309f5df --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# lunofono\_bundle [![Sponsor](https://img.shields.io/badge/-Sponsor-555555?style=flat-square)](https://github.com/llucax/llucax/blob/main/sponsoring-platforms.md)[![GitHub Sponsors](https://img.shields.io/badge/--ea4aaa?logo=github&style=flat-square)](https://github.com/sponsors/llucax)[![Liberapay](https://img.shields.io/badge/--f6c915?logo=liberapay&logoColor=black&style=flat-square)](https://liberapay.com/llucax/donate)[![Paypal](https://img.shields.io/badge/--0070ba?logo=paypal&style=flat-square)](https://www.paypal.com/donate?hosted_button_id=UZRR3REUC4SY2)[![Buy Me A Coffee](https://img.shields.io/badge/--ff813f?logo=buy-me-a-coffee&logoColor=white&style=flat-square)](https://www.buymeacoffee.com/llucax)[![Patreon](https://img.shields.io/badge/--f96854?logo=patreon&logoColor=white&style=flat-square)](https://www.patreon.com/llucax)[![Flattr](https://img.shields.io/badge/--6bc76b?logo=flattr&logoColor=white&style=flat-square)](https://flattr.com/@llucax) +[![CI](https://github.com/lunofono/lunofono_player/workflows/CI/badge.svg)](https://github.com/lunofono/lunofono_player/actions?query=branch%3Amain+workflow%3ACI+) +[![Pub Score](https://github.com/lunofono/lunofono_player/workflows/Pub%20Score/badge.svg)](https://github.com/lunofono/lunofono_player/actions?query=branch%3Amain+workflow%3A%22Pub+Score%22+) +[![Coverage](https://codecov.io/gh/lunofono/lunofono_player/branch/main/graph/badge.svg)](https://codecov.io/gh/lunofono/lunofono_player) + +A Flutter widget to play content bundles for Lunofono. + +## Contributing + +This project is written in [Flutter](https://flutter.dev/). Once you have +a working Flutter SDK installed, you can test it using `flutter test`. + +### Git Hooks + +This repository provides some useful Git hooks to make sure new commits have +some basic health. + +The hooks are provided in the `.githooks/` directory and can be easily used by +configuring git to use this directory for hooks instead of the default +`.git/hooks/`: + +```sh +git config core.hooksPath .githooks +``` + +So far there is a hook to prevent commits with the `WIP` word in the message to +be pushed, and one hook to run `flutter analyze` and `flutter test` before +a new commit is created. The later can take some time, but it can be easily +disabled temporarily by using `git commit --no-verify` if you are, for example, +just changing the README file or amending a commit message. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..6a0499d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:pedantic/analysis_options.1.9.0.yaml + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0a741cb --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..eeb859c --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,63 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.lunofono_player" + minSdkVersion 16 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..0104527 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a3f5255 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/lunofono_player/MainActivity.kt b/android/app/src/main/kotlin/com/example/lunofono_player/MainActivity.kt new file mode 100644 index 0000000..1b0c5b2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/lunofono_player/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.lunofono_player + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1f83a33 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..0104527 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..3100ad2 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..296b146 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..9d532b1 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,41 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..24544cb --- /dev/null +++ b/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 84f3d28555368a70270e9ac8390a9441df95e752 + channel: stable + +project_type: app diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..a135626 --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..0a741cb --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..3932aa9 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,63 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example" + minSdkVersion 16 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..c208884 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..55ca830 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..e793a00 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1f83a33 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..c208884 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..3100ad2 --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..a673820 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..296b146 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/assets/Farm-SoundBible.com-1720780826.md b/example/assets/Farm-SoundBible.com-1720780826.md new file mode 100644 index 0000000..21c8d4a --- /dev/null +++ b/example/assets/Farm-SoundBible.com-1720780826.md @@ -0,0 +1,5 @@ +Source: http://soundbible.com/1662-Farm.html +About: Sound of an actual farm with animals mostly sheep, goats, and other such animals. old mcdonald had one of these. +Title: Farm +License: Public Domain +Recorded by: stephan diff --git a/example/assets/Farm-SoundBible.com-1720780826.opus b/example/assets/Farm-SoundBible.com-1720780826.opus new file mode 100644 index 0000000..6ba8b9e Binary files /dev/null and b/example/assets/Farm-SoundBible.com-1720780826.opus differ diff --git a/example/assets/Farm-SoundBible.com-1720780826.wav b/example/assets/Farm-SoundBible.com-1720780826.wav new file mode 100644 index 0000000..a8d41ca Binary files /dev/null and b/example/assets/Farm-SoundBible.com-1720780826.wav differ diff --git a/example/assets/heilshorn-cows.jpg b/example/assets/heilshorn-cows.jpg new file mode 100644 index 0000000..074301d Binary files /dev/null and b/example/assets/heilshorn-cows.jpg differ diff --git a/example/assets/heilshorn-cows.md b/example/assets/heilshorn-cows.md new file mode 100644 index 0000000..dc2b328 --- /dev/null +++ b/example/assets/heilshorn-cows.md @@ -0,0 +1,5 @@ +Author: Leandro Lucarella +Title: Heilshorn Cows +E-Mail: llucax@gmail.com +License: Creative Commons Attribution-ShareAlike 4.0 International + https://creativecommons.org/licenses/by-sa/4.0/ diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..e96ef60 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..f2872cf --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1aec2aa --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,495 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* 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\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a28140c --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..28c6bf0 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f091b6b Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cde121 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..d0ef06e Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..dcdc230 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c8f9ed8 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..75b2d16 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c4df70d Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..6a84f41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d0e1f58 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..a060db6 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..dc3d57c --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,146 @@ +// Hide Image because We want to use the image from lunofono_bundle +import 'package:flutter/material.dart' hide Image; +import 'package:lunofono_player/lunofono_player.dart'; +import 'package:lunofono_bundle/lunofono_bundle.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'lunofono_player Demo', + theme: ThemeData( + // This is the theme of your application. + // + // Try running your application with "flutter run". You'll see the + // application has a blue toolbar. Then, without quitting the app, try + // changing the primarySwatch below to Colors.green and then invoke + // "hot reload" (press "r" in the console where you ran "flutter run", + // or simply save your changes to "hot reload" in a Flutter IDE). + // Notice that the counter didn't reset back to zero; the application + // is not restarted. + primarySwatch: Colors.blue, + // This makes the visual density adapt to the platform that you run + // the app on. For desktop platforms, the controls will be smaller and + // closer together (more dense) than on mobile platforms. + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: BundlePlayer(bundle), + ); + } +} + +final bundle = Bundle( + GridMenu( + rows: 1, + columns: 1, + buttons: [ + ColoredButton( + PlayContentAction( + MultiMedium( + AudibleMultiMediumTrack( + [ + Audio(Uri.parse('assets/Farm-SoundBible.com-1720780826.opus')), + ], + ), + backgroundTrack: VisualizableBackgroundMultiMediumTrack( + [ + Image(Uri.parse('assets/heilshorn-cows.jpg')), + ], + ), + ), + ), + Colors.amber, + ), + ], + ), +); + +class MyHomePage extends StatefulWidget { + MyHomePage({Key key, this.title}) : super(key: key); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Invoke "debug painting" (press "p" in the console, choose the + // "Toggle Debug Paint" action from the Flutter Inspector in Android + // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) + // to see the wireframe for each widget. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..82fb35d --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,28 @@ +name: lunofono_player_example +description: Demonstrate how to use the lunofono_player package + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.17.0 <2.0.0" + +dependencies: + flutter: + sdk: flutter + + lunofono_player: + path: ../ + + lunofono_bundle: + git: + url: https://github.com/lunofono/lunofono_bundle.git + ref: v1.x.x + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - assets/Farm-SoundBible.com-1720780826.opus + - assets/heilshorn-cows.jpg diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..ee6adf2 --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_player/lunofono_player.dart'; +import 'package:lunofono_player_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + expect(find.byType(BundlePlayer), findsOneWidget); + }); +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..e96ef60 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..f2872cf --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..12662e0 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,495 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* 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\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lunofonoPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lunofonoPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lunofonoPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a28140c --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..28c6bf0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f091b6b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cde121 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..d0ef06e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..dcdc230 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c8f9ed8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..75b2d16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c4df70d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..6a84f41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d0e1f58 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..04f75c0 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + lunofono_player + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/lib/lunofono_player.dart b/lib/lunofono_player.dart new file mode 100644 index 0000000..9604103 --- /dev/null +++ b/lib/lunofono_player.dart @@ -0,0 +1,5 @@ +/// A Widget to play Lunofono content bundles. +library lunofono_player; + +export 'src/bundle_player.dart' show BundlePlayer; +export 'src/platform_services.dart' show PlatformServices; diff --git a/lib/src/action_player.dart b/lib/src/action_player.dart new file mode 100644 index 0000000..36b2fbe --- /dev/null +++ b/lib/src/action_player.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart' show BuildContext; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Action, PlayContentAction; + +import 'button_player.dart' show ButtonPlayer; +import 'dynamic_dispatch_registry.dart' show DynamicDispatchRegistry; +import 'playable_player.dart' show PlayablePlayer; + +/// Register all builtin types +/// +/// When new builtin types are added, they should be registered by this +/// function, which is used by [ActionPlayerRegistry.builtin()]. +void _registerBuiltin(ActionPlayerRegistry registry) { + // New actions should be registered here + registry.register(PlayContentAction, + (action) => PlayContentActionPlayer(action as PlayContentAction)); +} + +/// A wrapper to manage how an [Action] is played by the player. +/// +/// This class also manages a registry of implementations for the different +/// concrete types of [Action]. To get an action wrapper, [ActionPlayer.wrap()] +/// should be used. +abstract class ActionPlayer { + /// The [ActionPlayerRegistry] used to dispatch the calls. + static var registry = ActionPlayerRegistry.builtin(); + + /// Dispatches the call dynamically by using the [registry]. + /// + /// The dispatch is done based on this [runtimeType], so only concrete leaf + /// types can be dispatched. It asserts if a type is not registered. + static ActionPlayer wrap(Action action) { + final wrap = registry.getFunction(action); + assert( + wrap != null, 'Unimplemented ActionPlayer for ${action.runtimeType}'); + return wrap(action); + } + + /// Constructs an [ActionPlayer]. + const ActionPlayer(); + + /// The underlaying model's [Action]. + Action get action; + + /// Perform the action for this. + void act(BuildContext context, ButtonPlayer button); +} + +/// A wrapper to play a [PlayContentAction]. +class PlayContentActionPlayer extends ActionPlayer { + /// The underlaying model's [Action]. + @override + final PlayContentAction action; + + /// The [PlayablePlayer] wrapping the [Playable] for this [content]. + final PlayablePlayer content; + + /// Constructs a [PlayContentActionPlayer] using [action] as the underlaying + /// [Action]. + PlayContentActionPlayer(this.action) + : assert(action != null), + content = PlayablePlayer.wrap(action.content); + + /// Plays the [content]. + @override + void act(BuildContext context, ButtonPlayer button) => + content.play(context, button.color); +} + +/// A function type to act on an [Action]. +typedef WrapFunction = ActionPlayer Function(Action action); + +/// A registry to map from [Action] types to [WrapFunction]. +class ActionPlayerRegistry + extends DynamicDispatchRegistry { + /// Constructs an empty registry. + ActionPlayerRegistry(); + + /// Constructs a registry with builtin types registered. + ActionPlayerRegistry.builtin() { + _registerBuiltin(this); + } +} diff --git a/lib/src/bundle_player.dart b/lib/src/bundle_player.dart new file mode 100644 index 0000000..2d44130 --- /dev/null +++ b/lib/src/bundle_player.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart' hide Orientation; + +import 'package:lunofono_bundle/lunofono_bundle.dart' show Bundle, Orientation; + +import 'menu_player.dart' show MenuPlayer; +import 'platform_services.dart' show PlatformServices; + +/// A [Bundle] player. +/// +/// This widget works as a player for [Bundle]s. It builds the UI for +/// the [Bundle.rootMenu] and from then on, it builds any sub-[Menu]s, +/// trigger actions on buttons and eventually plays the media. +class BundlePlayer extends StatefulWidget { + /// [Bundle] that will be played by this player. + final Bundle bundle; + + /// Platform services provider. + final PlatformServices platformServices; + + /// Creates a new instance to play [bundle]. + /// + /// If platformServices is null (the default), the global instance + /// [PlatformServices.instance] will be used. + /// + /// [bundle] cannot be null. + const BundlePlayer( + this.bundle, { + PlatformServices platformServices, + Key key, + }) : assert(bundle != null), + platformServices = platformServices ?? const PlatformServices(), + super(key: key); + + @override + _BundlePlayerState createState() => _BundlePlayerState(); +} + +/// A state for a [BundlePlayer]. +class _BundlePlayerState extends State { + /// The [MenuPlayer] used to play this [widget.menu]. + MenuPlayer rootMenu; + + /// Initialized this [_BundlePlayerState]. + /// + /// TODO: configure the player based on a default app config, overridden by + /// the user and the content bundle. + @override + void initState() { + super.initState(); + + rootMenu = MenuPlayer.wrap(widget.bundle.rootMenu); + + // Set fullscreen mode + widget.platformServices.setFullScreen(on: true).then((v) {}); + + // Set fixed orientation + widget.platformServices.setOrientation(Orientation.portrait).then((v) {}); + + // Take wakelock so the device isn't locked after some time inactive + widget.platformServices.inhibitScreenOff(on: true).then((v) {}); + } + + /// Builds the UI of this widget. + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: null, + body: rootMenu.build(context), + ); + } +} diff --git a/lib/src/button_player.dart b/lib/src/button_player.dart new file mode 100644 index 0000000..efb5a88 --- /dev/null +++ b/lib/src/button_player.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart' show BuildContext, ValueKey; + +import 'package:flutter_grid_button/flutter_grid_button.dart' + show GridButtonItem; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Action, Button, Color, ColoredButton; + +import 'action_player.dart' show ActionPlayer; +import 'dynamic_dispatch_registry.dart' show DynamicDispatchRegistry; + +export 'package:flutter_grid_button/flutter_grid_button.dart' + show GridButtonItem; + +/// Register all built-in types +/// +/// When new built-in types are added, they should be registered by this +/// function, which is used by [ButtonPlayerRegistry.builtin()]. +void _registerBuiltin(ButtonPlayerRegistry registry) { + // New wrappers should be registered here + registry.register( + ColoredButton, (button) => ColoredButtonPlayer(button as ColoredButton)); +} + +/// A wrapper to manage how a [Button] is played by the player. +/// +/// This class also manages a registry of implementations for the different +/// concrete types of [Button]. To get a button wrapper, [ButtonPlayer.wrap()] +/// should be used. +abstract class ButtonPlayer { + /// The [ButtonPlayerRegistry] used to dispatch the calls. + static var registry = ButtonPlayerRegistry.builtin(); + + /// Dispatches the call dynamically by using the [registry]. + /// + /// The dispatch is done based on this [runtimeType], so only concrete leaf + /// types can be dispatched. It asserts if a type is not registered. + static ButtonPlayer wrap(Button button) { + final wrap = registry.getFunction(button); + assert( + wrap != null, 'Unimplemented ButtonPlayer for ${button.runtimeType}'); + return wrap(button); + } + + /// Constructs a [ButtonPlayer]. + ButtonPlayer(Button button) + : assert(button != null), + action = ActionPlayer.wrap(button.action); + + /// The [ActionPlayer] wrapping the [Action] for this [button]. + final ActionPlayer action; + + /// The underlaying model's [Button]. + Button get button; + + /// The [Color] of the underlaying [button]. + /// + /// Returns null by default, as not all [Button] types have a color. + Color get color => null; + + /// Creates a [GridButtonItem] from the underlaying [button]. + /// + /// The [GridButtonItem.value] must always be assigned to this [ButtonPlayer]. + GridButtonItem create(BuildContext context); +} + +/// A wrapper to play a [ColoredButton]. +class ColoredButtonPlayer extends ButtonPlayer { + /// The underlaying model's [Button]. + @override + final ColoredButton button; + + /// Constructs a [ButtonPlayer] using [button] as the underlaying [Button]. + ColoredButtonPlayer(this.button) + : assert(button != null), + super(button); + + /// The [Color] of the underlaying [button]. + @override + Color get color => button.color; + + /// Creates a [GridButtonItem]. + /// + /// It uses [color] as the [GridButtonItem.color] and [this] as the + /// [GridButtonItem.value] and as a [ValueKey] for [GridButtonItem.key]. + @override + GridButtonItem create(BuildContext context) { + return GridButtonItem( + key: ValueKey(this), + title: '', + color: color, + value: this, + borderRadius: 50, + ); + } +} + +/// A function type to create a [ButtonPlayer] from a [Button]. +typedef WrapFunction = ButtonPlayer Function(Button button); + +/// A registry to map from [Button] types to a [WrapFunction]. +class ButtonPlayerRegistry + extends DynamicDispatchRegistry { + /// Constructs an empty registry. + ButtonPlayerRegistry(); + + /// Constructs a registry with builtin types registered. + ButtonPlayerRegistry.builtin() { + _registerBuiltin(this); + } +} diff --git a/lib/src/dynamic_dispatch_registry.dart b/lib/src/dynamic_dispatch_registry.dart new file mode 100644 index 0000000..8359b3e --- /dev/null +++ b/lib/src/dynamic_dispatch_registry.dart @@ -0,0 +1,44 @@ +/// A registry to enabled dynamic dispatch based on [Type.runtimeType]. +/// +/// This registry serves as a way to have dynamic dispatch to call functions +/// based on the concrete type of objects with a base class [B]. Types can be +/// registered with an assigned function of type [T] and then the specific +/// function can be obtained via [getFunction()]. +class DynamicDispatchRegistry { + /// The map from a concrete [Type] to a [T] function. + final _registry = {}; + + /// Constructs and empty registry. + DynamicDispatchRegistry(); + + /// True if the registry has no registered functions. + bool get isEmpty => _registry.isEmpty; + + /// Registers a [function] for [type]. + /// + /// If [type] was already registered, it is replaced and the old registered + /// [fucntion] is returned. Otherwise it returns null. + T register(Type type, T function) { + final old = _registry[type]; + _registry[type] = function; + return old; + } + + /// Removes the [T] function registered for [type]. + /// + /// Returns the registered [T] function for [type] or null if there was no + /// function registered. + T unregister(Type type) { + return _registry.remove(type); + } + + /// Gets the registered function [T] for [instance.runtimeType]. + /// + /// If there is no registered function, then null is returned. + T getFunction(B instance) => _registry[instance.runtimeType]; + + @override + String toString() { + return 'DynamicDispatchRegistry<$B, $T>($_registry)'; + } +} diff --git a/lib/src/media_player.dart b/lib/src/media_player.dart new file mode 100644 index 0000000..e76ad96 --- /dev/null +++ b/lib/src/media_player.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart' show ChangeNotifierProvider, Consumer; + +import 'package:lunofono_bundle/lunofono_bundle.dart' show MultiMedium; + +import 'media_player/controller_registry.dart' show ControllerRegistry; +import 'media_player/multi_medium_controller.dart' show MultiMediumController; +import 'media_player/multi_medium_player.dart' show MultiMediumPlayer; + +/// A media player widget. +/// +/// The player can play a [MultiMedium] via [SingleMediumController] plug-ins +/// that are obtained via the [ControllerRegistry]. It handles the playing and +/// synchronization of the [multimedium.mainTrack] and +/// [multimedium.backgroundTrack] and also the asynchronous nature of the player +/// controllers, by showing a progress indicator while the media is loading, and +/// the media afterwards, or a [MediaPlayerError] if an error occurred. +/// +/// If a medium is played for which there is no [SingleMediumController] +/// registered in the [ControllerRegistry], a [MediaPlayerError] will be shown +/// instead of that medium. +/// +/// All the orchestration behind the scenes is performed by +/// a [MultiMediumController] that is provided via a [ChangeNotifierProvider]. +class MediaPlayer extends StatelessWidget { + /// The medium to play by this player. + final MultiMedium multimedium; + + /// The background color for this player. + final Color backgroundColor; + + /// The action to perform when this player stops. + final void Function(BuildContext) onMediaStopped; + + /// The [ControllerRegistry] to create [SingleMediumController]s. + final ControllerRegistry registry; + + /// Constructs a new [MediaPlayer]. + /// + /// The player will play the [multimedium] with a background color + /// [backgroundColor] (or black if null is used). When the media stops + /// playing, either because it was played completely or because it was stopped + /// by the user, the [onMediaStopped] callback will be called (if non-null). + /// + /// If a [registry] is provided, then it is used to create the controller for + /// the media inside the [multimedium]. Otherwise + /// [ControllerRegistry.instance] is used. + const MediaPlayer({ + @required this.multimedium, + Color backgroundColor, + this.onMediaStopped, + this.registry, + Key key, + }) : assert(multimedium != null), + backgroundColor = backgroundColor ?? Colors.black, + super(key: key); + + /// Builds the UI for this widget. + @override + Widget build(BuildContext context) => + ChangeNotifierProvider( + create: (context) => MultiMediumController( + multimedium, + registry ?? ControllerRegistry.instance, + onMediumFinished: onMediaStopped, + )..initialize(context), + child: Consumer( + child: Material( + elevation: 0, + color: backgroundColor, + child: Center( + child: MultiMediumPlayer(), + ), + ), + builder: (context, model, child) { + return GestureDetector( + onTap: () { + // XXX: For now the stop reaction is hardcoded to the tap. + // Also we should handle errors in the pause()'s future + model.mainTrackController.pauseCurrent(context); + model.backgroundTrackController.pauseCurrent(context); + onMediaStopped?.call(context); + }, + child: child, + ); + }), + ); +} diff --git a/lib/src/media_player/controller_registry.dart b/lib/src/media_player/controller_registry.dart new file mode 100644 index 0000000..0f0279e --- /dev/null +++ b/lib/src/media_player/controller_registry.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart' show BuildContext; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show SingleMedium, Audio, Image, Video; + +import '../dynamic_dispatch_registry.dart' show DynamicDispatchRegistry; + +import 'single_medium_controller.dart'; + +export 'single_medium_controller.dart' show SingleMediumController; + +/// A function used to crate a [SingleMediumController]. +/// +/// This callback should never return null. +typedef ControllerCreateFunction = SingleMediumController Function( + SingleMedium medium, { + void Function(BuildContext) onMediumFinished, +}); + +/// A registry so [SingleMediumController]s can be created dynamically. +/// +/// This registry serves as a way to have dynamic dispatch to create controllers +/// for different kinds of [SingleMedium]s. +class ControllerRegistry + extends DynamicDispatchRegistry { + /// The global instance for the registry. + /// + /// This instance is initialized with all known media controllers. + static final instance = ControllerRegistry.defaults(); + + /// Constructs an empty controller registry. + ControllerRegistry(); + + /// Constructs a registry with the default [SingleMediumController] mappings. + ControllerRegistry.defaults() { + register( + Audio, + (SingleMedium medium, {void Function(BuildContext) onMediumFinished}) => + AudioPlayerController(medium, onMediumFinished: onMediumFinished), + ); + + register( + Image, + (SingleMedium medium, {void Function(BuildContext) onMediumFinished}) => + ImagePlayerController(medium, onMediumFinished: onMediumFinished), + ); + + register( + Video, + (SingleMedium medium, {void Function(BuildContext) onMediumFinished}) => + VideoPlayerController(medium, onMediumFinished: onMediumFinished), + ); + } +} diff --git a/lib/src/media_player/media_player_error.dart b/lib/src/media_player/media_player_error.dart new file mode 100644 index 0000000..51964ae --- /dev/null +++ b/lib/src/media_player/media_player_error.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; + +/// A widget to display errors instead of a player. +/// +/// This widget is used to display an error message when a [VideoPlayer] or an +/// [ImagePlayer] can't really start. +class MediaPlayerError extends StatelessWidget { + /// The error object to be displayed by this widget. + final dynamic error; + + /// Constructs a new [MediaPlayerError]. + /// + /// The widget will display the error description provided by [error]. + const MediaPlayerError(this.error, {Key key}) : super(key: key); + + /// Builds this [error] message. + /// + /// The message will be constructed depending on what type of error needs to + /// be shown. + String buildMessage() { + var details = ''; + var message = error.toString(); + if (error is PlatformException) { + final platformError = error as PlatformException; + if (platformError.details != null) { + details = ' (${platformError.details})'; + } + message = platformError.message; + } + return '$message$details'; + } + + /// Builds the UI for this widget. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + 'Media could not be played: ${buildMessage()}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline6.copyWith( + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/media_player/multi_medium_controller.dart b/lib/src/media_player/multi_medium_controller.dart new file mode 100644 index 0000000..2358838 --- /dev/null +++ b/lib/src/media_player/multi_medium_controller.dart @@ -0,0 +1,436 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show BuildContext, Widget; + +import 'package:meta/meta.dart' show protected; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show + MultiMedium, + SingleMedium, + NoTrack, + MultiMediumTrack, + BackgroundMultiMediumTrack, + VisualizableMultiMediumTrack, + VisualizableBackgroundMultiMediumTrack; + +import 'controller_registry.dart' show ControllerRegistry; +import 'single_medium_controller.dart' show SingleMediumController, Size; + +/// A controller for playing a [MultiMedium] and sending updates to the UI. +class MultiMediumController with ChangeNotifier, DiagnosticableTreeMixin { + MultiMediumTrackController _mainTrackController; + + /// The controller that takes care of playing the main track. + MultiMediumTrackController get mainTrackController => _mainTrackController; + + MultiMediumTrackController _backgroundTrackController; + + /// The controller that takes care of playing the background track. + MultiMediumTrackController get backgroundTrackController => + _backgroundTrackController; + + bool _allInitialized = false; + + /// True when all the media in both tracks is initialized. + bool get allInitialized => _allInitialized; + + /// The function that will be called when the main track finishes playing. + final void Function(BuildContext context) onMediumFinished; + + /// Constructs a [MultiMediumController] for playing [multimedium]. + /// + /// Both [multimedium] and [registry] must be non-null. If [onMediumFinished] + /// is provided, it will be called when the medium finishes playing the + /// [multimedium.mainTrack]. + MultiMediumController(MultiMedium multimedium, ControllerRegistry registry, + {this.onMediumFinished}) + : assert(multimedium != null), + assert(registry != null) { + _mainTrackController = MultiMediumTrackController.main( + track: multimedium.mainTrack, + registry: registry, + onMediumFinished: _onMainTrackFinished, + ); + _backgroundTrackController = MultiMediumTrackController.background( + track: multimedium.backgroundTrack, + registry: registry, + ); + } + + void _onMainTrackFinished(BuildContext context) { + backgroundTrackController.pauseCurrent(context); + onMediumFinished?.call(context); + } + + /// Initializes all media in both tracks. + /// + /// When initialization is done, [allInitialized] is set to true, it starts + /// playing the first medium in both tracks and it notifies the listeners. + Future initialize(BuildContext context) => Future.forEach( + [mainTrackController, backgroundTrackController], + (MultiMediumTrackController ts) => ts.initializeAll(context)) + .then( + (dynamic _) { + _allInitialized = true; + mainTrackController.playCurrent(context); + backgroundTrackController.playCurrent(context); + notifyListeners(); + }, + ); + + /// Disposes both tracks. + @override + Future dispose() async { + await Future.wait( + [mainTrackController.dispose(), backgroundTrackController.dispose()]); + super.dispose(); + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + final initialized = allInitialized ? 'initialized, ' : ''; + final main = 'main: $mainTrackController'; + final back = backgroundTrackController.isEmpty + ? '' + : ', background: $backgroundTrackController'; + return '$runtimeType($initialized$main$back)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(FlagProperty('allInitialized', + value: allInitialized, ifTrue: 'all tracks are initialized')) + ..add(ObjectFlagProperty('onMediumFinished', onMediumFinished, + ifPresent: 'notifies when all media finished')) + ..add(DiagnosticsProperty('main', mainTrackController, + expandableValue: true)); + if (backgroundTrackController.isNotEmpty) { + properties.add(DiagnosticsProperty( + 'background', backgroundTrackController, + expandableValue: true)); + } + } + + @override + List debugDescribeChildren() => [ + mainTrackController.toDiagnosticsNode(name: 'main'), + backgroundTrackController.toDiagnosticsNode(name: 'background'), + ]; +} + +/// A controller for playing a [MultiMediumTrack] and sending updates to the UI. +class MultiMediumTrackController with ChangeNotifier, DiagnosticableTreeMixin { + /// If true, then a proper widget needs to be shown for this track. + final bool isVisualizable; + + /// The list of [SingleMediumState] for this track. + /// + /// This stores the state of every individual medium in this track. + final mediaState = []; + + /// The [mediaState]'s index of the current medium being played. + /// + /// It can be as big as [mediaState.length], in which case it means the track + /// finished playing. + int currentIndex = 0; + + /// If true, all the media in this track has finished playing. + bool get isFinished => currentIndex >= mediaState.length; + + /// If true, then the track is empty ([mediaState] is empty). + bool get isEmpty => mediaState.isEmpty; + + /// If true, then the track is not empty (has some [mediaState]). + bool get isNotEmpty => mediaState.isNotEmpty; + + /// The current [SingleMediumState] being played, or null if [isFinished]. + SingleMediumState get current => isFinished ? null : mediaState[currentIndex]; + + /// The last [SingleMediumState] in this track. + SingleMediumState get last => mediaState.last; + + /// Constructs a [MultiMediumTrackController] from a [SingleMedium] list. + /// + /// The [media] list must be non-null and not empty. Also [visualizable] must + /// not be null and it indicates if the media should be displayed or not. + /// [registry] should also be non-null and it will be used to create the + /// [SingleMediumController] instances. If [onMediumFinished] is provided and + /// non-null, it will be called when all the tracks finished playing. + /// + /// When the underlaying [SingleMediumController] is created, its + /// `onMediumFinished` callback will be used to play the next media in the + /// [media] list. If last medium finished playing, then this + /// [onMediumFinished] will be called. + /// + /// If a [singleMediumStateFactory] is specified, it will be used to create + /// the [mediaState] elements, otherwise a default const + /// [SingleMediumStateFactory()] will be used. + @protected + MultiMediumTrackController.internal({ + @required List media, + @required bool visualizable, + @required ControllerRegistry registry, + void Function(BuildContext context) onMediumFinished, + SingleMediumStateFactory singleMediumStateFactory = + const SingleMediumStateFactory(), + }) : assert(media != null), + assert(media.isNotEmpty), + assert(visualizable != null), + assert(registry != null), + assert(singleMediumStateFactory != null), + isVisualizable = visualizable { + for (var i = 0; i < media.length; i++) { + final medium = media[i]; + final create = registry.getFunction(medium); + if (create == null) { + mediaState.add(singleMediumStateFactory.bad(medium, + 'Unsupported type ${medium.runtimeType} for ${medium.resource}')); + continue; + } + + final controller = create(medium, onMediumFinished: (context) { + currentIndex++; + if (isFinished) { + onMediumFinished?.call(context); + } else { + playCurrent(context); + } + notifyListeners(); + }); + mediaState.add(singleMediumStateFactory.good(controller)); + } + } + + /// Constructs an empty track state that [isFinished]. + @protected + MultiMediumTrackController.empty() + : isVisualizable = false, + currentIndex = 1; + + /// Constructs a [MultiMediumTrackController] for a [MultiMediumTrack]. + /// + /// [track] and [registry] must be non-null. If [onMediumFinished] is + /// provided and non-null, it will be called when all the tracks finished + /// playing. + MultiMediumTrackController.main({ + @required MultiMediumTrack track, + @required ControllerRegistry registry, + void Function(BuildContext context) onMediumFinished, + SingleMediumStateFactory singleMediumStateFactory = + const SingleMediumStateFactory(), + }) : this.internal( + media: track?.media, + visualizable: track is VisualizableMultiMediumTrack, + registry: registry, + onMediumFinished: onMediumFinished, + singleMediumStateFactory: singleMediumStateFactory, + ); + + /// Constructs a [MultiMediumTrackController] for + /// a [BackgroundMultiMediumTrack]. + /// + /// If [track] is [NoTrack], an empty [MultiMediumTrackController] will be + /// created, which is not visualizable and has already finished (and has an + /// empty [mediaState]). Otherwise a regular [MultiMediumTrackController] will + /// be constructed. + /// + /// [track] and [registry] must be non-null. + static MultiMediumTrackController background({ + @required BackgroundMultiMediumTrack track, + @required ControllerRegistry registry, + SingleMediumStateFactory singleMediumStateFactory = + const SingleMediumStateFactory(), + }) => + track is NoTrack + ? MultiMediumTrackController.empty() + : MultiMediumTrackController.internal( + media: track?.media, + visualizable: track is VisualizableBackgroundMultiMediumTrack, + registry: registry, + singleMediumStateFactory: singleMediumStateFactory, + ); + + /// Plays the current [SingleMediumState]. + Future playCurrent(BuildContext context) => current?.play(context); + + /// Pauses the current [SingleMediumState]. + Future pauseCurrent(BuildContext context) => current?.pause(context); + + /// Disposes all the [SingleMediumState] in [mediaState]. + @override + Future dispose() async { + // FIXME: Will only report the first error and discard the next. + await Future.forEach(mediaState, (SingleMediumState s) => s.dispose()); + super.dispose(); + } + + /// Initialize all (non-erroneous) the [mediaState] controllers. + /// + /// If a state is already erroneous, it is because there was a problem + /// creating the controller, so in this case it won't be initialized. + Future initializeAll(BuildContext context) => Future.wait(mediaState + .where((s) => !s.isErroneous) + .map((s) => s.initialize(context))); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + if (isEmpty) { + return '$runtimeType(empty)'; + } + final visualizable = isVisualizable ? 'visualizable' : 'audible'; + return '$runtimeType($visualizable, current: $currentIndex, ' + 'media: ${mediaState.length})'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + if (isEmpty) { + properties.add(FlagProperty('isEmpty', value: isEmpty, ifTrue: 'empty')); + return; + } + properties + ..add(FlagProperty('visualizable', + value: isVisualizable, ifTrue: 'visualizble', ifFalse: 'audible')) + ..add(IntProperty('currentIndex', currentIndex)) + ..add(IntProperty('mediaState.length', mediaState.length)); + } + + @override + List debugDescribeChildren() => [ + for (var i = 0; i < mediaState.length; i++) + mediaState[i].toDiagnosticsNode(name: '$i') + ]; +} + +/// Factory to construct [SingleMediumState]. +/// +/// This is used only for testing. +class SingleMediumStateFactory { + const SingleMediumStateFactory(); + SingleMediumState good(SingleMediumController controller) => + SingleMediumState(controller); + SingleMediumState bad(SingleMedium medium, dynamic error) => + SingleMediumState.erroneous(medium, error); +} + +/// A state of a medium on a [MultiMediumTrack]. +/// +/// The medium can have 3 states: +/// 1. Uninitialized, represented by [error] and [size] being null. +/// 2. Successfully initialized: represented by [size] being non-null. +/// 3. Erroneous: represented by [error] being non-null. The error can occur +/// while constructing the controller, [initialize()]ing, [play()]ing, +/// [pause()]ing, etc. Having both [error] and [size] non-null can happen if +/// the error happens after initialization is successful. +class SingleMediumState with DiagnosticableTreeMixin { + /// The medium this state tracks. + final SingleMedium medium; + + /// The player controller used to control this medium. + final SingleMediumController controller; + + /// The last error that happened while using this medium. + /// + /// It can be null, meaning there was no error so far. + dynamic error; + + /// The size of this medium. + /// + /// The size is only available after [initialize()] is successful, so if this + /// is non-null, it means the [controller] for this medium was initialized + /// successfully. + Size size; + + /// True if there was an error ([error] is non-null). + bool get isErroneous => error != null; + + /// True if it was successfully initialized ([size] != null). + /// + /// Even if it is initialized successfully, there could be an error after + /// that, so [isErroneous] should be always checked first before assuming this + /// medium is in a good state. + bool get isInitialized => size != null; + + /// The Key used by the widget produced by this [controller]. + Key get widgetKey => controller?.widgetKey; + + /// Constructs a new state using a [controller]. + /// + /// The [controller] must be non-null, [medium] will be set to + /// [controller.medium]. + SingleMediumState(this.controller) + : assert(controller != null), + medium = controller.medium; + + /// Constructs a new erroneous state. + /// + /// This is typically used when a [controller] couldn't be created. The + /// [medium] and [error] must be non-null and [controller] will be set to + /// null. + SingleMediumState.erroneous(this.medium, this.error) + : assert(medium != null), + assert(error != null), + controller = null; + + /// Initializes this medium's [controller]. + /// + /// Sets [size] on success, and [error] on error. Should be called only once + /// and before invoking any other method of this class. + Future initialize(BuildContext context) { + assert(size == null); + return controller.initialize(context).then((size) { + this.size = size; + }).catchError((dynamic error) => this.error = error); + } + + /// Plays this medium using [controller]. + /// + /// Sets [error] on error. + // FIXME: For now we show the error forever, eventually we probably have to + // show the error only for some time and then move to the next medium in the + // track. + Future play(BuildContext context) => controller + ?.play(context) + ?.catchError((dynamic error) => this.error = error); + + /// Pauses this medium using [controller]. + /// + /// Sets [error] on error. + // FIXME: For now we ignore pause() when isErroneous, eventually we probably + // have to show the error only for some time and then move to the next medium + // in the track. + Future pause(BuildContext context) => controller + ?.pause(context) + ?.catchError((dynamic error) => this.error = error); + + /// Disposes this medium's [controller]. + /// + /// Sets [error] on error. This state can't be used anymore after this method + /// is called, except for checking for [error]. + Future dispose() => + controller?.dispose()?.catchError((dynamic error) => this.error = error); + + /// Builds the widget to display this controller. + Widget build(BuildContext context) => controller.build(context); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + final sizeStr = + size == null ? 'uninitialized' : '${size.width}x${size.height}'; + final errorStr = error == null ? '' : 'error: $error'; + return '$runtimeType($sizeStr$errorStr)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('medium', medium)) + ..add(DiagnosticsProperty('error', error, defaultValue: null)) + ..add(DiagnosticsProperty('size', + size == null ? '' : '${size.width}x${size.height}')); + } +} diff --git a/lib/src/media_player/multi_medium_player.dart b/lib/src/media_player/multi_medium_player.dart new file mode 100644 index 0000000..3ee34ed --- /dev/null +++ b/lib/src/media_player/multi_medium_player.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart' show ChangeNotifierProvider, Consumer; + +import 'media_player_error.dart' show MediaPlayerError; +import 'multi_medium_controller.dart' + show MultiMediumController, MultiMediumTrackController; + +/// A player for a [MultiMedium]. +/// +/// This player is a [Consumer] of [MultiMediumController], which controls the +/// playing of the medium and just notifies this widget about updates. +/// +/// If the controller has an error, then a [MediaPlayerError] will be show to +/// display the error. +/// +/// Otherwise, if both main and background tracks initialization is completed, +/// then the state of the current medium of the visualizable track will be shown +/// using a [MultiMediumTrackPlayer]. But only if a track is visualizable. If +/// none of the tracks are visualizable (for example, it is an [Audible] main +/// track and an empty background track, then an empty [Container] will be +/// shown. +/// +/// If there is no error and tracks are not done with initialization, then +/// a [CircularProgressIndicator] will be shown to let the user know +/// initialization is still in progress. +class MultiMediumPlayer extends StatelessWidget { + /// Constructs a [MultiMediumPlayer]. + const MultiMediumPlayer({ + Key key, + }) : super(key: key); + + /// Creates a [MultiMediumTrackPlayer]. + /// + /// This is mainly useful for testing. + @protected + MultiMediumTrackPlayer createTrackPlayer() => MultiMediumTrackPlayer(); + + /// Builds the UI for this widget. + @override + Widget build(BuildContext context) => Consumer( + builder: (context, controller, child_) { + final mainTrack = controller.mainTrackController; + final backgroundTrack = controller.backgroundTrackController; + + if (controller.allInitialized) { + final mainWidget = ChangeNotifierProvider.value( + value: mainTrack, + child: createTrackPlayer(), + ); + final backgroundWiget = ChangeNotifierProvider.value( + value: backgroundTrack, + child: createTrackPlayer(), + ); + + // The first widget in the stack, should be visualizable track. If + // there is no visualizable track, then the mainTrack takes + // precedence, so it will be centered in the stack. + final firstWidget = mainTrack.isVisualizable + ? mainWidget + : backgroundTrack.isVisualizable + ? backgroundWiget + : mainWidget; + final children = [Center(child: firstWidget)]; + + // The second widget in the stack should be the main track if the + // first widget was the background track (as we know there is a main + // track too). If the first widget in the stack is the main track, + // we only add the background track if it is not empty. + if (identical(firstWidget, backgroundWiget)) { + children.add(mainWidget); + } else if (backgroundTrack.isNotEmpty) { + children.add(backgroundWiget); + } + + return Stack( + // This alignment will count only for the seconds widget in the + // stack, as the first one will be forcibly centered. + alignment: Alignment.bottomCenter, + children: children, + ); + } + + // Still initializing + return MediaProgressIndicator( + visualizable: + mainTrack.isVisualizable || backgroundTrack.isVisualizable, + ); + }, + ); +} + +/// A player for a [MultiMediumTrack]. +/// +/// This player is a [Consumer] of [MultiMediumTrackController], which controls +/// the playing of the track and just notifies this widget about updates. +/// +/// If the track has an error, then a [MediaPlayerError] will be show to display +/// the error. +/// +/// Otherwise, if all media in the track is done with the initializing, then the +/// current track's medium displayed using [SingleMediumController.build()]. If +/// the aspect ratio of the medium is landscape, then it will be wrapped in +/// a [RotatedBox] too. +/// +/// If there is no error and initialization is not done yet, then +/// a [CircularProgressIndicator] will be shown to let the user know +/// initialization is still in progress. +class MultiMediumTrackPlayer extends StatelessWidget { + /// Constructs a [MultiMediumTrackPlayer]. + const MultiMediumTrackPlayer({Key key}) : super(key: key); + + /// Builds the UI for this widget. + @override + Widget build(BuildContext context) => + // if we finished playing, we still want to show the last medium for the + // main track, so the fade-out effect has still something to show. + // For the background track, the user might want to be able to override + // this behaviour. See: + // https://gitlab.com/lunofono/lunofono-app/-/issues/37 + Consumer( + builder: (context, controller, child_) { + final current = controller.current ?? controller.last; + + if (current.isErroneous) { + return MediaPlayerError(current.error); + } + + if (current.isInitialized) { + if (!controller.isVisualizable) { + // FIXME: This is a bit hacky. At some point it might be better to + // have 2 build methods in SingleMediumController: buildAudible() + // and buildVisualizable() and use then depeding on what kind of + // track we are showing. + return Container(key: current.widgetKey); + } + + var widget = current.build(context); + if (current.size.width > current.size.height) { + widget = RotatedBox( + quarterTurns: 1, + child: widget, + ); + } + return widget; + } + + // Still initializing + return MediaProgressIndicator( + visualizable: controller.isVisualizable); + }, + ); +} + +/// A progress indicator that shows what kind of media is loading. +/// +/// If [isVisualizable] is true, a [Icons.local_movies] will be shown, otherwise +/// a [Icons.music_note] will be shown. A [CircularProgressIndicator] is always +/// shown in the back. +class MediaProgressIndicator extends StatelessWidget { + /// If true, a [Icons.local_movies] is shown, otherwise a [Icons.music_note]. + final bool isVisualizable; + + /// Constructs a [MediaProgressIndicator] setting if it's [visualizable]. + const MediaProgressIndicator({@required bool visualizable}) + : assert(visualizable != null), + isVisualizable = visualizable; + + /// Builds the widget. + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Icon(isVisualizable ? Icons.local_movies : Icons.music_note), + CircularProgressIndicator(), + ], + ); + } +} diff --git a/lib/src/media_player/single_medium_controller.dart b/lib/src/media_player/single_medium_controller.dart new file mode 100644 index 0000000..56a3fd3 --- /dev/null +++ b/lib/src/media_player/single_medium_controller.dart @@ -0,0 +1,246 @@ +import 'dart:async' show Completer; + +import 'package:flutter/material.dart'; + +import 'package:video_player/video_player.dart' as video_player; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show SingleMedium, UnlimitedDuration; +import 'package:pausable_timer/pausable_timer.dart' show PausableTimer; + +export 'dart:ui' show Size; + +/// A controller for a specific type of media. +/// +/// This class is intended to be used as a base class. +abstract class SingleMediumController { + /// The medium to play by this player controller. + final SingleMedium medium; + + /// The key to use to create the main [Widget] in [build()]. + final Key widgetKey; + + /// The callback to be called when the medium finishes playing. + /// + /// This callback is called when the medium finishes playing by itself. + final void Function(BuildContext) onMediumFinished; + + /// The timer used to finish the medium if it has a maximum duration. + /// + /// If [medium.maxDuration] is [UnlimitedDuration] then this timer is null. + /// + /// If not, the timer is created by [initialize] (setting the timer to run + /// [pause] and [onMediumFinished] when it expires) but it's not started until + /// [play] is called. Then [play] and [pause] will start and pause the timer. + @protected + PausableTimer maxDurationTimer; + + /// {@template ui_player_media_player_medium_player_controller_initialize} + /// Initializes this controller, getting the size of the media to be played. + /// + /// When initialization is done, this function returns the size of the media + /// being played. + /// + /// The [build()] method should never be called before the initialization is + /// done. + /// {@endtemplate} + @mustCallSuper + Future initialize(BuildContext context) async { + final futureNull = Future.value(null); + if (medium.maxDuration == const UnlimitedDuration()) return futureNull; + + maxDurationTimer = PausableTimer(medium.maxDuration, () async { + await pause(context); + onMediumFinished?.call(context); + }); + + return futureNull; + } + + /// Play the [medium] controlled by this controller. + @mustCallSuper + Future play(BuildContext context) async => maxDurationTimer?.start(); + + /// Pause the [medium] controlled by this controller. + @mustCallSuper + Future pause(BuildContext context) async => maxDurationTimer?.pause(); + + /// Builds the [Widget] that plays the medium this controller controls. + Widget build(BuildContext context); + + /// Disposes this controller. + @mustCallSuper + Future dispose() async => maxDurationTimer?.cancel(); + + /// {@template ui_player_media_player_medium_player_controller_constructor} + /// Constructs a controller to play the [medium]. + /// + /// If a [onMediumFinished] callback is provided, it will be called when the + /// media finishes playing. + /// + /// If a [widgetKey] is provided, it will be used to create the main player + /// [Widget] in the [build()] function. + /// {@endtemplate} + SingleMediumController(this.medium, {this.onMediumFinished, this.widgetKey}) + : assert(medium != null); +} + +/// A video player controller. +class VideoPlayerController extends SingleMediumController { + /// The video player controller. + video_player.VideoPlayerController _videoPlayerController; + + /// The video player controller. + video_player.VideoPlayerController get videoPlayerController => + _videoPlayerController; + + /// {@macro ui_player_media_player_medium_player_controller_constructor} + VideoPlayerController( + SingleMedium medium, { + void Function(BuildContext) onMediumFinished, + Key widgetKey, + }) : super(medium, onMediumFinished: onMediumFinished, widgetKey: widgetKey); + + /// Disposes this controller. + @override + Future dispose() => Future.wait([ + _videoPlayerController?.dispose(), + super.dispose() + ].where((f) => f != null)); + + /// Creates a new [video_player.VideoPlayerController]. + /// + /// This method is provided mostly only for testing, so a fake type of video + /// player controller can be *injected* by tests. + @protected + video_player.VideoPlayerController createVideoPlayerController() { + return video_player.VideoPlayerController.asset( + medium.resource.toString(), + videoPlayerOptions: video_player.VideoPlayerOptions(mixWithOthers: true), + ); + } + + /// {@macro ui_player_media_player_medium_player_controller_initialize} + @override + Future initialize(BuildContext context) async { + VoidCallback listener; + listener = () { + final value = _videoPlayerController.value; + // value.duration can be null during initialization + // If the position reaches the duration (we use >= just to be extra + // careful) and it is not playing anymore, we assumed it finished playing. + // Also this should happen once and only once, as we don't expose any + // seeking or loop playing. + if (value.duration != null && + value.position >= value.duration && + !value.isPlaying) { + onMediumFinished?.call(context); + _videoPlayerController.removeListener(listener); + } + }; + + _videoPlayerController = createVideoPlayerController(); + _videoPlayerController.addListener(listener); + + await Future.wait( + [super.initialize(context), _videoPlayerController.initialize()]); + + return _videoPlayerController.value.size; + } + + /// Play the [medium] controlled by this controller. + @override + Future play(BuildContext context) => + Future.wait([super.play(context), _videoPlayerController.play()]); + + /// Pause the [medium] controlled by this controller. + @override + Future pause(BuildContext context) => + Future.wait([super.pause(context), _videoPlayerController.pause()]); + + /// Builds the [Widget] that plays the medium this controller controls. + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: _videoPlayerController.value.aspectRatio, + child: video_player.VideoPlayer(_videoPlayerController), + key: widgetKey, + ); + } +} + +/// An audio player controller. +/// +/// Since audio is not really visible, this player will return an empty +/// [Container] in the [build()] method. Users are free to omit using the +/// [build()] method at all. +class AudioPlayerController extends VideoPlayerController { + /// {@macro ui_player_media_player_medium_player_controller_constructor} + AudioPlayerController( + SingleMedium medium, { + void Function(BuildContext) onMediumFinished, + Key widgetKey, + }) : super(medium, onMediumFinished: onMediumFinished, widgetKey: widgetKey); + + /// Builds the [Widget] that plays the medium this controller controls. + /// + /// Since audio is not really visible, this player will return an empty + /// [Container]. Users are free to omit using this method. + @override + Widget build(BuildContext context) { + // Audios are invisible, so there is nothing to show + return Container(key: widgetKey); + } +} + +/// An image player controller. +class ImagePlayerController extends SingleMediumController { + /// The image that this controller will [build()]. + Image _image; + + /// The image that this controller will [build()]. + Image get image => _image; + + /// {@macro ui_player_media_player_medium_player_controller_constructor] + ImagePlayerController( + SingleMedium medium, { + void Function(BuildContext) onMediumFinished, + Key widgetKey, + }) : super(medium, onMediumFinished: onMediumFinished, widgetKey: widgetKey); + + /// {@macro ui_player_media_player_medium_player_controller_initialize] + @override + Future initialize(BuildContext context) async { + final completer = Completer(); + Size size; + + _image = Image.asset( + medium.resource.toString(), + bundle: DefaultAssetBundle.of(context), + key: widgetKey, + ); + + _image.image.resolve(ImageConfiguration()).addListener( + ImageStreamListener( + (ImageInfo info, bool _) { + size = Size( + info.image.width.toDouble(), info.image.height.toDouble()); + completer.complete(); + }, + onError: (dynamic error, StackTrace stackTrace) { + completer.completeError(error, stackTrace); + }, + ), + ); + + await Future.wait([super.initialize(context), completer.future]); + + return size; + } + + /// Builds the [Widget] that plays the medium this controller controls. + @override + Widget build(BuildContext context) { + return _image; + } +} diff --git a/lib/src/menu_player.dart b/lib/src/menu_player.dart new file mode 100644 index 0000000..fc94e55 --- /dev/null +++ b/lib/src/menu_player.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_grid_button/flutter_grid_button.dart' + show GridButton, GridButtonItem; + +import 'package:lunofono_bundle/lunofono_bundle.dart' show Menu, GridMenu; + +import 'button_player.dart' show ButtonPlayer; +import 'dynamic_dispatch_registry.dart' show DynamicDispatchRegistry; + +/// Register all builtin types +/// +/// When new builtin types are added, they should be registered by this +/// function, which is used by [MenuPlayerRegistry.builtin()]. +void _registerBuiltin(MenuPlayerRegistry registry) { + // New menus should be registered here + registry.register(GridMenu, (menu) => GridMenuPlayer(menu as GridMenu)); +} + +/// A wrapper to manage how a [Menu] is played by the player. +/// +/// This class also manages a registry of implementations for the different +/// concrete types of [Menu]. To get an menu wrapper, [MenuPlayer.wrap()] +/// should be used. +abstract class MenuPlayer { + /// The [MenuPlayerRegistry] used to dispatch the calls. + static var registry = MenuPlayerRegistry.builtin(); + + /// Dispatches the call dynamically by using the [registry]. + /// + /// The dispatch is done based on this [runtimeType], so only concrete leaf + /// types can be dispatched. It asserts if a type is not registered. + static MenuPlayer wrap(Menu menu) { + final wrap = registry.getFunction(menu); + assert(wrap != null, 'Unimplemented MenuPlayer for ${menu.runtimeType}'); + return wrap(menu); + } + + /// The underlaying model's [Menu]. + Menu get menu; + + /// Builds the UI for this [menu]. + Widget build(BuildContext context); +} + +class GridMenuPlayer extends MenuPlayer { + /// The [GridMenu] that this widget represents. + @override + final GridMenu menu; + + /// The number of rows in the underlaying [menu]. + int get rows => menu.rows; + + /// The number of columns in the underlaying [menu]. + int get columns => menu.columns; + + /// The list of [ButtonPlayer]s wrapping this [menu.buttons]. + final List buttons; + + /// Gets the [Button] at position ([row], [column]) in the grid. + /// + /// TODO: Swap rows and cols if the orientation is forced? + ButtonPlayer buttonAt(int row, int column) => buttons[row * columns + column]; + + /// Constructs a [GridMenuPlayer] from a [GridMenu]. + /// + /// This also wrap all the [menu.buttons] to store [ButtonPlayer]s instead. + GridMenuPlayer(this.menu) + : assert(menu != null), + buttons = List.from( + menu.buttons.map((b) => ButtonPlayer.wrap(b))); + + /// Builds the UI for this [menu]. + @override + Widget build(BuildContext context) { + return GridMenuWidget(this); + } +} + +/// A Widget to play a [GridMenu]. +class GridMenuWidget extends StatelessWidget { + /// The [GridMenu] that this widget represents. + final GridMenuPlayer menu; + + /// Constructs a widget for a [menu]. + const GridMenuWidget( + this.menu, { + Key key, + }) : super(key: key); + + /// Builds the UI for this widget. + @override + Widget build(BuildContext context) { + const textStyle = TextStyle(fontSize: 26); + return Padding( + padding: const EdgeInsets.all(18.0), + child: GridButton( + textStyle: textStyle, + hideSurroundingBorder: true, + onPressed: (dynamic value) { + final button = value as ButtonPlayer; + button.action.act(context, button); + }, + items: _buildButtonsGrid(context), + ), + ); + } + + /// Builds a grid of buttons for the menu. + /// + /// The grid is represented by a list of rows and a row is a list of buttons + /// (the columns of that row). + List> _buildButtonsGrid(BuildContext context) { + final rows = >[]; + for (var i = 0; i < menu.rows; i++) { + rows.add([]); + for (var j = 0; j < menu.columns; j++) { + rows.last.add(menu.buttonAt(i, j).create(context)); + } + } + return rows; + } +} + +/// A function type to build the UI of a [Menu]. +typedef WrapFunction = MenuPlayer Function(Menu menu); + +/// A registry to map from [Menu] types to [BuildFunction]. +class MenuPlayerRegistry extends DynamicDispatchRegistry { + /// Constructs an empty registry. + MenuPlayerRegistry(); + + /// Constructs a registry with builtin types registered. + MenuPlayerRegistry.builtin() { + _registerBuiltin(this); + } +} diff --git a/lib/src/platform_services.dart b/lib/src/platform_services.dart new file mode 100644 index 0000000..a539baf --- /dev/null +++ b/lib/src/platform_services.dart @@ -0,0 +1,77 @@ +import 'package:flutter/services.dart' + show SystemChrome, DeviceOrientation, SystemUiOverlay; + +import 'package:meta/meta.dart' show required; + +import 'package:wakelock/wakelock.dart' show Wakelock; + +import 'package:lunofono_bundle/lunofono_bundle.dart' show Orientation; + +/// Provides services dependent on the platform. +/// +/// Usually these services are presented as singletons or global functions, +/// making them hard to integrate with tests. +class PlatformServices { + /// Creates a new PlatformServices instance. + const PlatformServices(); + + /// Set fullscreen mode on or off. + Future setFullScreen({@required bool on}) async { + return SystemChrome.setEnabledSystemUIOverlays( + on ? [] : SystemUiOverlay.values); + } + + /// Set the preferred screen orientation(s). + /// + /// If [Orientation.inherited] is used, we don't do anything, assuming the + /// current orientation is the preferred one. + Future setOrientation(Orientation orientation) async { + Future setTo(List o) async { + return SystemChrome.setPreferredOrientations(o); + } + + switch (orientation) { + case Orientation.inherited: + // It doesn't make a lot of sense to call this function with this + // orientation, but if it happens, we don't do anything, assuming the + // current orientation is preferred. + return; + + case Orientation.automatic: + return setTo(DeviceOrientation.values); + + case Orientation.portrait: + return setTo([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + case Orientation.portraitUp: + return setTo([DeviceOrientation.portraitUp]); + + case Orientation.portraitDown: + return setTo([DeviceOrientation.portraitDown]); + + case Orientation.landscape: + return setTo([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + + case Orientation.landscapeLeft: + return setTo([DeviceOrientation.landscapeLeft]); + + case Orientation.landscapeRight: + return setTo([DeviceOrientation.landscapeRight]); + } + } + + /// If on, the device will not turn off the screen after some timeout. + /// + /// This is also known as "wakelock", when taking the "wakelock" means the + /// device doesn't go to sleep. + Future inhibitScreenOff({@required bool on}) async { + // Take wakelock so the device isn't locked after some time inactive + return on ? Wakelock.enable() : Wakelock.disable(); + } +} diff --git a/lib/src/playable_player.dart b/lib/src/playable_player.dart new file mode 100644 index 0000000..345cac6 --- /dev/null +++ b/lib/src/playable_player.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart' + show BuildContext, Navigator, MaterialPageRoute, Colors; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Playable, MultiMedium, SingleMedium, Audio, Image, Video, Color; + +import 'dynamic_dispatch_registry.dart' show DynamicDispatchRegistry; +import 'media_player.dart' show MediaPlayer; + +/// Register all builtin types +/// +/// When new builtin types are added, they should be registered by this +/// function, which is used by [PlayablePlayerRegistry.builtin()]. +void _registerBuiltin(PlayablePlayerRegistry registry) { + // New actions should be registered here + MultiMediumPlayer SingleMediumPlayer(Playable playable) => + MultiMediumPlayer(MultiMedium.fromSingleMedium(playable as SingleMedium)); + registry.register(Audio, (playable) => SingleMediumPlayer(playable)); + registry.register(Image, (playable) => SingleMediumPlayer(playable)); + registry.register(Video, (playable) => SingleMediumPlayer(playable)); + registry.register( + MultiMedium, (playable) => MultiMediumPlayer(playable as MultiMedium)); +} + +/// A wrapper to manage how a [Playable] is played by the player. +/// +/// This class also manages a registry of implementations for the different +/// concrete types of [Playable]. To get a playable wrapper, [PlayablePlayer.wrap()] +/// should be used. +abstract class PlayablePlayer { + /// The [PlayablePlayerRegistry] used to dispatch the calls. + static var registry = PlayablePlayerRegistry.builtin(); + + /// Dispatches the call dynamically by using the [registry]. + /// + /// The dispatch is done based on this [runtimeType], so only concrete leaf + /// types can be dispatched. It asserts if a type is not registered. + static PlayablePlayer wrap(Playable playable) { + final wrap = registry.getFunction(playable); + assert(wrap != null, + 'Unimplemented PlayablePlayer for ${playable.runtimeType}'); + return wrap(playable); + } + + /// The underlaying model's [Playable]. + Playable get playable; + + /// Plays this [Playable] with an optional [backgroundColor]. + void play(BuildContext context, [Color backgroundColor]); +} + +class MultiMediumPlayer extends PlayablePlayer { + /// The underlaying model's [SingleMedium]. + @override + final MultiMedium playable; + + /// Constructs a [SingleMediumPlayer] using [playable] as the underlaying + /// [Playable]. + MultiMediumPlayer(this.playable) : assert(playable != null); + + /// Plays a [SingleMedium] by pushing a new page with a [MediaPlayer]. + /// + /// If [backgroundColor] is provided and non-null, it will be used as the + /// [MediaPlayer.backgroundColor]. Otherwise, [Colors.black] will be used. + @override + void play(BuildContext context, [Color backgroundColor]) { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => MediaPlayer( + multimedium: playable, + backgroundColor: backgroundColor ?? Colors.black, + onMediaStopped: (context) => Navigator.pop(context), + ), + ), + ); + } +} + +// From here, it's just boilerplate and it shouldn't be changed unless the +// architecture changes. + +/// A function type to play a [Playable]. +typedef WrapFunction = PlayablePlayer Function(Playable playable); + +/// A registry to map from [Playable] types [PlayFunction]. +class PlayablePlayerRegistry + extends DynamicDispatchRegistry { + /// Constructs an empty registry. + PlayablePlayerRegistry(); + + /// Constructs a registry with builtin types registered. + PlayablePlayerRegistry.builtin() { + _registerBuiltin(this); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..642f601 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,532 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "11.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.40.4" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.0-nullsafety.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.1" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.2" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "7.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.3" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0-nullsafety.3" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.5" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.8" + equatable: + dependency: transitive + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.5" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_grid_button: + dependency: "direct main" + description: + name: flutter_grid_button + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.7" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.4" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.4" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3-nullsafety.1" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.4" + lunofono_bundle: + dependency: "direct main" + description: + path: "." + ref: "v1.x.x" + resolved-ref: "405e576fc3f5b42c85ab8483f6bf1c94c7b56628" + url: "https://github.com/lunofono/lunofono_bundle.git" + source: git + version: "1.0.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10-nullsafety.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0-nullsafety.3" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7" + mockito: + dependency: "direct dev" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.3" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" + node_interop: + dependency: transitive + description: + name: node_interop + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + node_io: + dependency: transitive + description: + name: node_io + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.12" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0-nullsafety.1" + pausable_timer: + dependency: "direct main" + description: + name: pausable_timer + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+1" + pedantic: + dependency: "direct dev" + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0-nullsafety.1" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0-nullsafety.1" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.2+2" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.4" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.9" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.8" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+1" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.2" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10-nullsafety.1" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0-nullsafety.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0-nullsafety.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + test: + dependency: "direct dev" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0-nullsafety.5" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.19-nullsafety.2" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.12-nullsafety.5" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0-nullsafety.3" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.3" + video_player: + dependency: "direct main" + description: + name: video_player + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.12+3" + video_player_platform_interface: + dependency: "direct dev" + description: + name: video_player_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.0" + wakelock: + dependency: "direct main" + description: + name: wakelock + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4+2" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+15" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.3" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" +sdks: + dart: ">=2.10.0-110 <2.11.0" + flutter: ">=1.17.0 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1c44796 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,42 @@ +name: lunofono_player +description: A Flutter widget to play content bundles for Lunofono, the media + player for kids. +version: 0.0.1 +repository: https://github.com/lunofono/lunofono_player + +# To be removed when it is published +publish_to: none + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.17.0 <2.0.0" + +dependencies: + flutter: + sdk: flutter + + lunofono_bundle: + git: + url: https://github.com/lunofono/lunofono_bundle.git + ref: v1.x.x + + flutter_grid_button: ^1.1.6 + pausable_timer: ^0.1.0 + provider: ^4.3.2+2 + # Lock version to 0.10.12+3 because of a regression. This should be updated + # back to '>=0.10.11+2 <2.0.0' after the regression is fixed: + # https://github.com/flutter/flutter/issues/68010 + video_player: 0.10.12+3 + wakelock: ^0.1.4+1 + +dev_dependencies: + flutter_test: + sdk: flutter + + mockito: ^4.1.1 + pedantic: ^1.9.0 + test: ^1.15.2 + video_player_platform_interface: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec diff --git a/test/asset_bundle/AssetManifest.json b/test/asset_bundle/AssetManifest.json new file mode 100644 index 0000000..06c949d --- /dev/null +++ b/test/asset_bundle/AssetManifest.json @@ -0,0 +1,11 @@ +{ + "assets/10x10-red.png": [ + "assets/10x10-red.png" + ], + "assets/16x9-green.png": [ + "assets/16x9-green.png" + ], + "assets/9x16-blue.png": [ + "assets/9x16-blue.png" + ] +} diff --git a/test/asset_bundle/assets/10x10-red.png b/test/asset_bundle/assets/10x10-red.png new file mode 100644 index 0000000..9af09e8 Binary files /dev/null and b/test/asset_bundle/assets/10x10-red.png differ diff --git a/test/asset_bundle/assets/16x9-green.png b/test/asset_bundle/assets/16x9-green.png new file mode 100644 index 0000000..ec38b3e Binary files /dev/null and b/test/asset_bundle/assets/16x9-green.png differ diff --git a/test/asset_bundle/assets/9x16-blue.png b/test/asset_bundle/assets/9x16-blue.png new file mode 100644 index 0000000..8a3d73d Binary files /dev/null and b/test/asset_bundle/assets/9x16-blue.png differ diff --git a/test/asset_bundle/assets/tone-sin-480Hz-3dBFS-0.1s.wav b/test/asset_bundle/assets/tone-sin-480Hz-3dBFS-0.1s.wav new file mode 100644 index 0000000..6bf687f Binary files /dev/null and b/test/asset_bundle/assets/tone-sin-480Hz-3dBFS-0.1s.wav differ diff --git a/test/unit/action_player_test.dart b/test/unit/action_player_test.dart new file mode 100644 index 0000000..e8b53d0 --- /dev/null +++ b/test/unit/action_player_test.dart @@ -0,0 +1,127 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' show BuildContext; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Action, Color, Playable, PlayContentAction; +import 'package:lunofono_player/src/action_player.dart'; +import 'package:lunofono_player/src/button_player.dart' show ButtonPlayer; +import 'package:lunofono_player/src/playable_player.dart' + show PlayablePlayer, PlayablePlayerRegistry; + +class FakePlayable extends Playable { + BuildContext playedContext; + Color playedColor; + + void expectCalled(BuildContext context, Color backgroundColor) { + expect(playedContext, context); + expect(playedColor, backgroundColor); + } +} + +class FakePlayablePlayer extends PlayablePlayer { + @override + final FakePlayable playable; + @override + void play(BuildContext context, [Color backgroundColor]) { + playable.playedContext = context; + playable.playedColor = backgroundColor; + } + + FakePlayablePlayer(this.playable) : assert(playable != null); +} + +class FakeAction extends Action {} + +class FakeActionPlayer extends ActionPlayer { + Action calledAction; + BuildContext calledContext; + ButtonPlayer calledButton; + + @override + final FakeAction action; + @override + void act(BuildContext context, ButtonPlayer button) { + calledAction = action; + calledContext = context; + calledButton = button; + } + + FakeActionPlayer(this.action) : assert(action != null); +} + +class FakeButtonPlayer extends Fake implements ButtonPlayer { + @override + Color get color => Color(0x12345678); +} + +class FakeContext extends Fake implements BuildContext {} + +void main() { + group('ActionPlayer', () { + final oldActionRegistry = ActionPlayer.registry; + FakeContext fakeContext; + FakeAction fakeAction; + FakeButtonPlayer fakeButton; + + setUp(() { + fakeContext = FakeContext(); + fakeAction = FakeAction(); + fakeButton = FakeButtonPlayer(); + }); + + tearDown(() => ActionPlayer.registry = oldActionRegistry); + + test('empty', () { + ActionPlayer.registry = ActionPlayerRegistry(); + expect(ActionPlayer.registry.isEmpty, isTrue); + expect(() => ActionPlayer.wrap(fakeAction).act(fakeContext, fakeButton), + throwsAssertionError); + }); + + test('registration and base ActionPlayer implementation works', () { + ActionPlayer.registry = ActionPlayerRegistry(); + ActionPlayer.registry.register( + FakeAction, (action) => FakeActionPlayer(action as FakeAction)); + final actionPlayer = ActionPlayer.wrap(fakeAction) as FakeActionPlayer; + actionPlayer.act(fakeContext, fakeButton); + expect(actionPlayer.calledAction, same(fakeAction)); + expect(actionPlayer.calledContext, same(fakeContext)); + expect(actionPlayer.calledButton, same(fakeButton)); + }); + + group('PlayContentAction builtin', () { + var fakePlayable = FakePlayable(); + final oldPlayableRegistry = PlayablePlayer.registry; + + setUp(() { + PlayablePlayer.registry = PlayablePlayerRegistry(); + PlayablePlayer.registry.register(FakePlayable, + (playable) => FakePlayablePlayer(playable as FakePlayable)); + fakePlayable = FakePlayable(); + }); + + tearDown(() => PlayablePlayer.registry = oldPlayableRegistry); + + test('constructor throws if action is null', () { + expect(() => PlayContentActionPlayer(null), throwsAssertionError); + }); + + test('dynamic dispatch', () { + final Action action = PlayContentAction(fakePlayable); + final actionPlayer = ActionPlayer.wrap(action); + actionPlayer.act(fakeContext, fakeButton); + fakePlayable.expectCalled(fakeContext, fakeButton.color); + }); + + test('direct call', () { + final action = ActionPlayer.wrap(PlayContentAction(fakePlayable)); + action.act(fakeContext, fakeButton); + fakePlayable.expectCalled(fakeContext, fakeButton.color); + }); + }); + }); +} diff --git a/test/unit/bundle_player_test.dart b/test/unit/bundle_player_test.dart new file mode 100644 index 0000000..411f2b7 --- /dev/null +++ b/test/unit/bundle_player_test.dart @@ -0,0 +1,82 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' hide Orientation; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Bundle, Menu, Orientation; +import 'package:lunofono_player/src/platform_services.dart' + show PlatformServices; +import 'package:lunofono_player/src/bundle_player.dart' show BundlePlayer; +import 'package:lunofono_player/src/menu_player.dart' + show MenuPlayer, MenuPlayerRegistry; + +void main() { + group('BundlePlayer', () { + test('constructor asserts on a null bundle', () { + expect(() => BundlePlayer(null), throwsAssertionError); + }); + + testWidgets( + 'PlatformServices are called and the rootMenu is built', + (WidgetTester tester) async { + // Setup fake environment + final services = FakePlatformServices(); + MenuPlayer.registry = MenuPlayerRegistry(); + MenuPlayer.registry + .register(FakeMenu, (m) => FakeMenuPlayer(m as FakeMenu)); + + // Create test bundle, player and app + final testBundle = Bundle(FakeMenu()); + final bundlePlayer = + BundlePlayer(testBundle, platformServices: services); + // XXX: We need to make it inside a MaterialApp, see the first test. + final testBundleApp = MaterialApp(title: 'Test', home: bundlePlayer); + + // Pump the test app + await tester.pumpWidget(testBundleApp); + + // Services should have been called + expect(services.calledFullScreen, isTrue); + expect(services.calledOrientation, Orientation.portrait); + expect(services.calledInhibitScreenOff, isTrue); + + // The FakeMenu should have been built. + expect(find.byKey(FakeMenuPlayer.globalKey), findsOneWidget); + }, + ); + }); +} + +class FakeMenu extends Menu {} + +class FakeMenuPlayer extends MenuPlayer { + static Key globalKey = GlobalKey(debugLabel: 'FakeMenuPlayerKey'); + FakeMenuPlayer(this.menu) : assert(menu != null); + @override + final FakeMenu menu; + @override + Widget build(BuildContext context) { + return Container(child: Text('FakeMenu'), key: globalKey); + } +} + +class FakePlatformServices extends PlatformServices { + bool calledFullScreen; + Orientation calledOrientation; + bool calledInhibitScreenOff; + @override + Future setFullScreen({@required bool on}) async { + calledFullScreen = on; + } + + @override + Future setOrientation(Orientation orientation) async { + calledOrientation = orientation; + } + + @override + Future inhibitScreenOff({@required bool on}) async { + calledInhibitScreenOff = on; + } +} diff --git a/test/unit/button_player_test.dart b/test/unit/button_player_test.dart new file mode 100644 index 0000000..1743151 --- /dev/null +++ b/test/unit/button_player_test.dart @@ -0,0 +1,92 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' show ValueKey, BuildContext; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Action, Button, Color, ColoredButton; +import 'package:lunofono_player/src/action_player.dart'; +import 'package:lunofono_player/src/button_player.dart'; + +class FakeAction extends Action {} + +class FakeActionPlayer extends ActionPlayer { + @override + final FakeAction action; + @override + void act(BuildContext context, ButtonPlayer button) {} + FakeActionPlayer(this.action) : assert(action != null); +} + +class FakeButton extends Button { + FakeButton() : super(FakeAction()); +} + +class FakeButtonPlayer extends ButtonPlayer { + @override + final FakeButton button; + + @override + GridButtonItem create(BuildContext context) { + return GridButtonItem(color: color, value: action, title: ''); + } + + FakeButtonPlayer(this.button) : super(button); +} + +class FakeContext extends Fake implements BuildContext {} + +void main() { + group('ButtonPlayer', () { + final oldButtonRegistry = ButtonPlayer.registry; + FakeButton fakeButton; + FakeContext fakeContext; + Color color; + + setUp(() { + fakeButton = FakeButton(); + fakeContext = FakeContext(); + color = Color(0x12ab4523); + + ActionPlayer.registry = ActionPlayerRegistry(); + ActionPlayer.registry + .register(FakeAction, (a) => FakeActionPlayer(a as FakeAction)); + }); + + tearDown(() => ButtonPlayer.registry = oldButtonRegistry); + + test('empty registry is empty', () { + ButtonPlayer.registry = ButtonPlayerRegistry(); + expect(ButtonPlayer.registry, isEmpty); + expect(() => ButtonPlayer.wrap(fakeButton), throwsAssertionError); + }); + + test('registration and base ButtonPlayer implementation works', () { + expect(() => FakeButtonPlayer(null), throwsAssertionError); + ButtonPlayer.registry = ButtonPlayerRegistry(); + ButtonPlayer.registry + .register(FakeButton, (b) => FakeButtonPlayer(b as FakeButton)); + final buttonPlayer = ButtonPlayer.wrap(fakeButton); + expect(buttonPlayer.color, isNull); + expect(buttonPlayer.action.action, fakeButton.action); + final gridItem = buttonPlayer.create(fakeContext); + expect(gridItem.color, buttonPlayer.color); + expect(gridItem.value, buttonPlayer.action); + expect(gridItem.title, ''); + }); + + test('builtin types are registered and work as expected', () { + expect(() => ColoredButtonPlayer(null), throwsAssertionError); + final coloredButton = ColoredButton(FakeAction(), color); + final buttonPlayer = ButtonPlayer.wrap(coloredButton); + final gridButtonItem = buttonPlayer.create(fakeContext); + expect(gridButtonItem.color, color); + expect(gridButtonItem.key, isA()); + expect(gridButtonItem.title, ''); + expect(gridButtonItem.value, buttonPlayer); + expect(gridButtonItem.borderRadius, 50); + }); + }); +} diff --git a/test/unit/dynamic_dispatch_registry_test.dart b/test/unit/dynamic_dispatch_registry_test.dart new file mode 100644 index 0000000..0c38b98 --- /dev/null +++ b/test/unit/dynamic_dispatch_registry_test.dart @@ -0,0 +1,98 @@ +@Tags(['unit', 'util']) + +import 'dart:ui' show VoidCallback; + +import 'package:test/test.dart'; + +import 'package:lunofono_player/src/dynamic_dispatch_registry.dart'; + +class Base {} + +class Item extends Base { + int x; +} + +class NoSubClass {} + +void main() { + group('DynamicLibrary', () { + group('from empty', () { + DynamicDispatchRegistry registry; + void baseFunction() {} + void itemFunction1() {} + void itemFunction2() {} + + setUp(() { + registry = DynamicDispatchRegistry(); + }); + + test('is empty', () { + expect(registry.isEmpty, isTrue); + }); + + test('getting the function for an unregistered type returns null', () { + var function = registry.getFunction(Item()); + expect(registry.isEmpty, isTrue); + expect(function, isNull); + expect(registry.getFunction(Base()), isNull); + }); + + test('unregistering an unregistered type returns null', () { + final oldRegisteredFunction = registry.unregister(Base); + expect(registry.isEmpty, isTrue); + expect(oldRegisteredFunction, isNull); + }); + + test('registering a superclass returns the right functions', () { + final old = registry.register(Base, baseFunction); + expect(old, isNull); + expect(registry.isEmpty, isFalse); + expect(registry.getFunction(Base()), baseFunction); + expect(registry.getFunction(Item()), isNull); + }); + + test('registering a subclass returns the right functions', () { + final old = registry.register(Item, itemFunction1); + expect(registry.isEmpty, isFalse); + expect(old, isNull); + expect(registry.getFunction(Item()), itemFunction1); + expect(registry.getFunction(Base()), isNull); + }); + + test('registering a subclass and subclass returns the right functions', + () { + registry.register(Base, baseFunction); + final old = registry.register(Item, itemFunction1); + expect(old, isNull); + expect(registry.isEmpty, isFalse); + expect(registry.getFunction(Item()), itemFunction1); + expect(registry.getFunction(Base()), baseFunction); + }); + + test('can register and unregister functions', () { + registry.register(Item, itemFunction1); + final old = registry.unregister(Item); + expect(old, itemFunction1); + expect(registry.isEmpty, isTrue); + }); + + test('re-registering returns the old function and used the new one', () { + registry.register(Item, itemFunction1); + final old = registry.register(Item, itemFunction2); + expect(old, itemFunction1); + expect(registry.isEmpty, isFalse); + expect(registry.getFunction(Item()), itemFunction2); + }); + + test('toString()', () { + expect(registry.toString(), + 'DynamicDispatchRegistry void>({})'); + registry.register(Base, baseFunction); + expect( + registry.toString(), + 'DynamicDispatchRegistry void>' + '({Base: Closure: () => void})'); + }); + }); + }); +} diff --git a/test/unit/media_player/controller_registry_test.dart b/test/unit/media_player/controller_registry_test.dart new file mode 100644 index 0000000..636562f --- /dev/null +++ b/test/unit/media_player/controller_registry_test.dart @@ -0,0 +1,61 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' show BuildContext; + +import 'package:test/test.dart'; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show SingleMedium, Audio, Image, Video; +import 'package:lunofono_player/src/media_player/controller_registry.dart'; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart'; + +class FakeSingleMedium extends SingleMedium { + FakeSingleMedium(Uri resource) : super(resource); +} + +void main() { + group('ControllerRegistry', () { + test('default constructor', () { + SingleMediumController f(SingleMedium medium, + {void Function(BuildContext) onMediumFinished}) => + null; + final fakeMedium = FakeSingleMedium(Uri.parse('fake-medium')); + final registry = ControllerRegistry(); + expect(registry.isEmpty, isTrue); + final oldRegisteredFunction = registry.register(FakeSingleMedium, f); + expect(oldRegisteredFunction, isNull); + expect(registry.isEmpty, isFalse); + final create = registry.getFunction(fakeMedium); + expect(create, f); + }); + + void testDefaults(ControllerRegistry registry) { + expect(registry.isEmpty, isFalse); + + final audio = Audio(Uri.parse('fake-audio')); + var controller = registry.getFunction(audio)(audio); + expect(controller, isA()); + expect(controller.medium, audio); + + final image = Image(Uri.parse('fake-image')); + controller = registry.getFunction(image)(image); + expect(controller, isA()); + expect(controller.medium, image); + + final video = Video(Uri.parse('fake-video')); + controller = registry.getFunction(video)(video); + expect(controller, isA()); + expect(controller.medium, video); + } + + test('.defaults() constructor', () { + testDefaults(ControllerRegistry.defaults()); + }); + + test('.instance', () { + final registry = ControllerRegistry.instance; + testDefaults(registry); + expect(registry, same(ControllerRegistry.instance)); + }); + }); +} diff --git a/test/unit/media_player/media_player_error_test.dart b/test/unit/media_player/media_player_error_test.dart new file mode 100644 index 0000000..2825dd4 --- /dev/null +++ b/test/unit/media_player/media_player_error_test.dart @@ -0,0 +1,38 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' show Directionality, TextDirection; +import 'package:flutter/services.dart' show PlatformException; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_player/src/media_player/media_player_error.dart' + show MediaPlayerError; + +import '../../util/finders.dart' show findSubString; + +void main() { + group('MediaPlayerError', () { + testWidgets('Exception', (WidgetTester tester) async { + final exception = Exception('This is an error'); + final widget = MediaPlayerError(exception); + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: widget)); + expect( + find.text('Media could not be played: ${exception}'), findsWidgets); + }); + + testWidgets('PlatformException', (WidgetTester tester) async { + final exception = PlatformException( + code: 'Error Code', + message: 'Error message', + details: 'Error details', + ); + final widget = MediaPlayerError(exception); + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: widget)); + expect(findSubString('Media could not be played'), findsOneWidget); + expect(findSubString(exception.message), findsOneWidget); + expect(findSubString(exception.details.toString()), findsOneWidget); + }); + }); +} diff --git a/test/unit/media_player/multi_medium_controller/multi_medium_track_controller_test.dart b/test/unit/media_player/multi_medium_controller/multi_medium_track_controller_test.dart new file mode 100644 index 0000000..833c500 --- /dev/null +++ b/test/unit/media_player/multi_medium_controller/multi_medium_track_controller_test.dart @@ -0,0 +1,602 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart'; +import 'package:lunofono_player/src/media_player/controller_registry.dart' + show ControllerRegistry; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart' + show SingleMediumController, Size; + +import 'package:lunofono_player/src/media_player/multi_medium_controller.dart' + show + MultiMediumTrackController, + SingleMediumState, + SingleMediumStateFactory; + +import '../../../util/foundation.dart' show FakeDiagnosticableMixin; + +void main() { + group('MultiMediumTrackController', () { + final registry = ControllerRegistry(); + _registerControllers(registry); + + final _fakeSingleMediumStateFactory = _FakeSingleMediumStateFactory(); + + final audibleMedium = _FakeAudibleSingleMedium(size: Size(0.0, 0.0)); + final audibleMedium2 = _FakeAudibleSingleMedium(size: Size(10.0, 12.0)); + final audibleMainTrack = _FakeAudibleMultiMediumTrack([audibleMedium]); + final audibleBakgroundTrack = + _FakeAudibleBackgroundMultiMediumTrack([audibleMedium]); + + group('constructor', () { + group('.internal() asserts on', () { + test('null media', () { + expect( + () => _TestMultiMediumTrackController( + media: null, + visualizable: true, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('empty media', () { + expect( + () => _TestMultiMediumTrackController( + media: [], + visualizable: true, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null visualizable', () { + expect( + () => _TestMultiMediumTrackController( + visualizable: null, + media: audibleMainTrack.media, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null registry', () { + expect( + () => _TestMultiMediumTrackController( + registry: null, + media: audibleMainTrack.media, + visualizable: true, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null singleMediumStateFactory', () { + expect( + () => _TestMultiMediumTrackController( + singleMediumStateFactory: null, + media: audibleMainTrack.media, + visualizable: true, + registry: registry, + ), + throwsAssertionError, + ); + }); + }); + + group('.main() asserts on', () { + test('null track', () { + expect( + () => MultiMediumTrackController.main( + track: null, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null registry', () { + expect( + () => MultiMediumTrackController.main( + registry: null, + track: audibleMainTrack, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null singleMediumStateFactory', () { + expect( + () => MultiMediumTrackController.main( + singleMediumStateFactory: null, + track: audibleMainTrack, + registry: registry, + ), + throwsAssertionError, + ); + }); + }); + + group('.background() asserts on', () { + test('null track', () { + expect( + () => MultiMediumTrackController.background( + track: null, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null registry', () { + expect( + () => MultiMediumTrackController.background( + registry: null, + track: audibleBakgroundTrack, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ), + throwsAssertionError, + ); + }); + test('null singleMediumStateFactory', () { + expect( + () => MultiMediumTrackController.background( + singleMediumStateFactory: null, + track: audibleBakgroundTrack, + registry: registry, + ), + throwsAssertionError, + ); + }); + }); + + void testContructorWithMedia( + MultiMediumTrackController controller, List media) { + expect(controller.isVisualizable, isFalse); + expect(controller.mediaState.length, media.length); + expect(controller.currentIndex, 0); + expect(controller.isFinished, isFalse); + expect(controller.isEmpty, isFalse); + expect(controller.isNotEmpty, isTrue); + expect(controller.current, controller.mediaState.first); + expect(controller.last, controller.mediaState.last); + // The current/fist one is OK but uninitialized + expect(controller.current.controller, isNotNull); + expect(controller.current.isInitialized, isFalse); + expect(controller.current.isErroneous, isFalse); + // The last one is an unregistered medium, so it is erroneous + expect(controller.last.controller, isNull); + expect(controller.last.isInitialized, isFalse); + expect(controller.last.isErroneous, isTrue); + } + + test('.main() create mediaState correctly', () { + final track = _FakeAudibleMultiMediumTrack([ + audibleMedium, + _FakeUnregisteredAudibleSingleMedium(), + ]); + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + testContructorWithMedia(controller, track.media); + }); + + test('.background() create mediaState correctly', () { + final track = _FakeAudibleBackgroundMultiMediumTrack([ + audibleMedium, + _FakeUnregisteredAudibleSingleMedium(), + ]); + final controller = MultiMediumTrackController.background( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + testContructorWithMedia(controller, track.media); + }); + + test('.background() create empty track with NoTrack', () { + final track = NoTrack(); + final controller = MultiMediumTrackController.background( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + expect(controller.isVisualizable, isFalse); + expect(controller.isFinished, isTrue); + expect(controller.isEmpty, isTrue); + expect(controller.isNotEmpty, isFalse); + }); + }); + + test('initializeAll() initializes media controllers', () async { + final track = _FakeAudibleMultiMediumTrack([ + audibleMedium, + _FakeUnregisteredAudibleSingleMedium(), + ]); + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + await controller.initializeAll(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current.isInitialized, isTrue); + expect(controller.current.isErroneous, isFalse); + expect(controller.current.asFake.calls, ['initialize']); + expect(controller.last.isInitialized, isFalse); + expect(controller.last.isErroneous, isTrue); + }); + + test("play() doesn't end with state without controller", () async { + final track = _FakeAudibleMultiMediumTrack([ + audibleMedium, + _FakeUnregisteredAudibleSingleMedium(), + ]); + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + + await controller.initializeAll(_FakeContext()); + var first = controller.current; + + await controller.playCurrent(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current, same(first)); + expect(controller.current.asFake.calls, ['initialize', 'play']); + expect(controller.last.isInitialized, isFalse); + expect(controller.last.isErroneous, isTrue); + + // after the current track finished, the next should be played, but since + // it is erroneous without controller, nothing happens (we'll have to + // implement a default or error SingleMediumController eventually) + controller.current.controller.onMediumFinished(_FakeContext()); + expect(first.asFake.calls, ['initialize', 'play']); + expect(controller.isFinished, isFalse); + expect(controller.current, same(controller.last)); + expect(controller.last.isInitialized, isFalse); + expect(controller.last.isErroneous, isTrue); + }); + + test('play-pause-next cycle works without onMediumFinished', () async { + final track = _FakeAudibleMultiMediumTrack([ + audibleMedium, + audibleMedium2, + ]); + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + + await controller.initializeAll(_FakeContext()); + expect(controller.current.asFake.calls, ['initialize']); + expect(controller.last.asFake.calls, ['initialize']); + final first = controller.current; + + await controller.playCurrent(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current, same(first)); + expect(controller.current.asFake.calls, ['initialize', 'play']); + expect(controller.last.asFake.calls, ['initialize']); + + await controller.pauseCurrent(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current, same(first)); + expect(controller.current.asFake.calls, ['initialize', 'play', 'pause']); + expect(controller.last.asFake.calls, ['initialize']); + + await controller.playCurrent(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current, same(first)); + expect(controller.current.asFake.calls, + ['initialize', 'play', 'pause', 'play']); + expect(controller.last.asFake.calls, ['initialize']); + + // after the current track finished, the next one is played + controller.current.controller.onMediumFinished(_FakeContext()); + expect(controller.isFinished, isFalse); + expect(controller.current, same(controller.last)); + expect(first.asFake.calls, ['initialize', 'play', 'pause', 'play']); + expect(controller.last.asFake.calls, ['initialize', 'play']); + + // after the last track finished, the controller should be finished + controller.current.controller.onMediumFinished(_FakeContext()); + expect(controller.isFinished, isTrue); + expect(controller.current, isNull); + expect(first.asFake.calls, ['initialize', 'play', 'pause', 'play']); + expect(controller.last.asFake.calls, ['initialize', 'play']); + + // If we dispose the controller, + await controller.dispose(); + expect(first.asFake.calls, + ['initialize', 'play', 'pause', 'play', 'dispose']); + expect(controller.last.asFake.calls, ['initialize', 'play', 'dispose']); + }); + + test('onMediumFinished is called', () async { + final track = + _FakeAudibleMultiMediumTrack([audibleMedium, audibleMedium2]); + + var finished = false; + + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + onMediumFinished: (BuildContext context) => finished = true); + + await controller.initializeAll(_FakeContext()); + expect(finished, isFalse); + // plays first + await controller.playCurrent(_FakeContext()); + expect(finished, isFalse); + // ends first, second starts playing + controller.current.controller.onMediumFinished(_FakeContext()); + expect(finished, isFalse); + // ends second, onMediumFinished should be called + controller.current.controller.onMediumFinished(_FakeContext()); + expect(finished, isTrue); + expect(controller.isFinished, isTrue); + }); + + test('listening for updates work', () async { + final track = + _FakeAudibleMultiMediumTrack([audibleMedium, audibleMedium2]); + final controller = MultiMediumTrackController.main( + track: track, + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory); + + var notifyCalls = 0; + controller.addListener(() => notifyCalls += 1); + + await controller.initializeAll(_FakeContext()); + expect(notifyCalls, 0); + // plays first + await controller.playCurrent(_FakeContext()); + expect(notifyCalls, 0); + // ends first, second starts playing + controller.current.controller.onMediumFinished(_FakeContext()); + expect(notifyCalls, 1); + // ends second, onMediumFinished should be called + controller.current.controller.onMediumFinished(_FakeContext()); + expect(notifyCalls, 2); + await controller.pauseCurrent(_FakeContext()); + expect(notifyCalls, 2); + await controller.dispose(); + expect(notifyCalls, 2); + }); + + test('toString()', () async { + var controller = MultiMediumTrackController.main( + track: _FakeAudibleMultiMediumTrack([audibleMedium, audibleMedium2]), + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ); + + expect(controller.toString(), + 'MultiMediumTrackController(audible, current: 0, media: 2)'); + await controller.initializeAll(_FakeContext()); + expect(controller.toString(), + 'MultiMediumTrackController(audible, current: 0, media: 2)'); + + controller = MultiMediumTrackController.background( + track: const NoTrack(), + registry: registry, + ); + expect( + MultiMediumTrackController.background( + track: const NoTrack(), + registry: registry, + singleMediumStateFactory: _fakeSingleMediumStateFactory, + ).toString(), + 'MultiMediumTrackController(empty)', + ); + }); + + test('debugFillProperties() and debugDescribeChildren()', () async { + final identityHash = RegExp(r'#[0-9a-f]{5}'); + + // XXX: No fake singleMediumStateFactory here because we would have to + // fake all the diagnostics class hierarchy too, which is overkill. + expect( + MultiMediumTrackController.main( + track: _FakeAudibleMultiMediumTrack([audibleMedium, audibleMedium2]), + registry: registry, + ).toStringDeep().replaceAll(identityHash, ''), + 'MultiMediumTrackController\n' + ' │ audible\n' + ' │ currentIndex: 0\n' + ' │ mediaState.length: 2\n' + ' │\n' + ' ├─0: SingleMediumState\n' + ' │ medium: Instance of \'_FakeAudibleSingleMedium\'\n' + ' │ size: \n' + ' │\n' + ' └─1: SingleMediumState\n' + ' medium: Instance of \'_FakeAudibleSingleMedium\'\n' + ' size: \n' + '', + ); + expect( + MultiMediumTrackController.background( + track: const NoTrack(), + registry: registry, + ).toStringDeep().replaceAll(identityHash, ''), + 'MultiMediumTrackController\n' + ' empty\n' + '', + ); + }); + }); +} + +class _TestMultiMediumTrackController extends MultiMediumTrackController { + _TestMultiMediumTrackController({ + @required List media, + @required bool visualizable, + @required ControllerRegistry registry, + void Function(BuildContext context) onMediumFinished, + SingleMediumStateFactory singleMediumStateFactory, + }) : super.internal( + media: media, + visualizable: visualizable, + registry: registry, + onMediumFinished: onMediumFinished, + singleMediumStateFactory: singleMediumStateFactory); +} + +class _FakeContext extends Fake implements BuildContext {} + +abstract class _FakeSingleMedium extends Fake implements SingleMedium { + final Size size; + final dynamic error; + final Key widgetKey; + _FakeSingleMedium({ + this.size, + this.error, + Key widgetKey, + }) : assert(error != null && size == null || error == null && size != null), + widgetKey = widgetKey ?? GlobalKey(debugLabel: 'widgetKey'); + + @override + Uri get resource => Uri.parse('medium.resource'); +} + +class _FakeAudibleSingleMedium extends _FakeSingleMedium implements Audible { + _FakeAudibleSingleMedium({ + Size size, + dynamic error, + Key widgetKey, + }) : super(size: size, error: error, widgetKey: widgetKey); +} + +class _FakeAudibleMultiMediumTrack extends Fake + implements AudibleMultiMediumTrack { + @override + final List media; + _FakeAudibleMultiMediumTrack(this.media); +} + +class _FakeAudibleBackgroundMultiMediumTrack extends Fake + implements AudibleBackgroundMultiMediumTrack { + @override + final List media; + _FakeAudibleBackgroundMultiMediumTrack(this.media); +} + +class _FakeUnregisteredAudibleSingleMedium extends Fake + implements SingleMedium, Audible { + @override + Uri get resource => Uri.parse('medium.resource'); +} + +void _registerControllers(ControllerRegistry registry) { + SingleMediumController createController(SingleMedium medium, + {void Function(BuildContext) onMediumFinished}) { + final fakeMedium = medium as _FakeSingleMedium; + final c = _FakeSingleMediumController(fakeMedium, + onMediumFinished: onMediumFinished); + return c; + } + + registry.register(_FakeAudibleSingleMedium, createController); +} + +class _FakeSingleMediumStateFactory extends Fake + implements SingleMediumStateFactory { + @override + SingleMediumState good(SingleMediumController controller) => + _FakeSingleMediumState( + medium: controller.medium, + controller: controller as _FakeSingleMediumController); + + @override + SingleMediumState bad(SingleMedium medium, dynamic error) => + _FakeSingleMediumState(medium: medium, error: error); +} + +class _FakeSingleMediumState extends Fake + with FakeDiagnosticableMixin + implements SingleMediumState { + @override + SingleMedium medium; + + @override + final _FakeSingleMediumController controller; + + @override + Size size; + + @override + dynamic error; + + _FakeSingleMediumState({this.medium, this.controller, this.error}); + + final calls = []; + + Future _errorOrOk(String name, [Size size]) async { + calls.add(name); + if (controller?.medium?.error != null) { + throw controller.medium.error; + } + if (size != null) { + this.size = size; + } + } + + @override + bool get isInitialized => size != null; + + @override + bool get isErroneous => error != null; + + @override + Future initialize(BuildContext context) => + _errorOrOk('initialize', controller?.medium?.size); + + @override + Future play(BuildContext context) => _errorOrOk('play'); + + @override + Future pause(BuildContext context) => _errorOrOk('pause'); + + @override + Future dispose() => _errorOrOk('dispose'); + + @override + Widget build(BuildContext context) { + calls.add('build'); + return Container(key: controller.widgetKey); + } +} + +class _FakeSingleMediumController extends Fake + implements SingleMediumController { + @override + _FakeSingleMedium medium; + @override + final void Function(BuildContext) onMediumFinished; + _FakeSingleMediumController( + this.medium, { + this.onMediumFinished, + }) : assert(medium != null); +} + +extension _AsFakeSingleMediumState on SingleMediumState { + _FakeSingleMediumState get asFake => this as _FakeSingleMediumState; +} + +// vim: set foldmethod=syntax foldminlines=3 : diff --git a/test/unit/media_player/multi_medium_controller/single_medium_state_test.dart b/test/unit/media_player/multi_medium_controller/single_medium_state_test.dart new file mode 100644 index 0000000..66651b2 --- /dev/null +++ b/test/unit/media_player/multi_medium_controller/single_medium_state_test.dart @@ -0,0 +1,313 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart'; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart' + show SingleMediumController, Size; + +import 'package:lunofono_player/src/media_player/multi_medium_controller.dart' + show SingleMediumState, SingleMediumStateFactory; + +void main() { + void verifyStateInvariants( + SingleMediumState state, _FakeSingleMediumController controller) { + expect(state.controller, same(controller)); + expect(state.widgetKey, controller.widgetKey); + } + + void verifyStateInitialization( + SingleMediumState state, _FakeSingleMediumController controller) { + verifyStateInvariants(state, controller); + expect(state.error, isNull); + expect(state.isErroneous, isFalse); + expect(state.size, isNull); + expect(state.isInitialized, isFalse); + expect(controller.calls, isEmpty); + } + + void verifyStateError( + SingleMediumState state, _FakeSingleMediumController controller) { + verifyStateInvariants(state, controller); + expect(state.size, isNull); + expect(state.isInitialized, isFalse); + expect(state.error, controller.medium.info.exception); + expect(state.isErroneous, isTrue); + } + + group('SingleMediumState', () { + test('.erroneous() initializes with error', () async { + final medium = _FakeSingleMedium('medium', size: Size(0.0, 0.0)); + + expect(() => SingleMediumState.erroneous(null, 'Error'), + throwsAssertionError); + + expect(() => SingleMediumState.erroneous(medium, null), + throwsAssertionError); + + final state = SingleMediumState.erroneous(medium, 'Error 123'); + expect(state.controller, isNull); + expect(state.widgetKey, isNull); + expect(state.error, 'Error 123'); + expect(state.isErroneous, isTrue); + expect(state.size, isNull); + expect(state.isInitialized, isFalse); + }); + + group('on bad medium', () { + Exception error; + _FakeSingleMedium medium; + _FakeSingleMediumController controller; + SingleMediumState state; + + setUp(() { + error = Exception('Initialization Error'); + medium = _FakeSingleMedium('bad-medium', exception: error); + controller = _FakeSingleMediumController(medium); + state = SingleMediumState(controller); + }); + + test('the state is properly initialized', () { + verifyStateInitialization(state, controller); + }); + + test('.initialize() fills error', () async { + await state.initialize(_FakeContext()); + verifyStateError(state, controller); + }); + + test('.play() fills error', () async { + await state.play(_FakeContext()); + verifyStateError(state, controller); + }); + + test('.pause() fills error', () async { + await state.pause(_FakeContext()); + verifyStateError(state, controller); + }); + + test('.dispose() fills error', () async { + await state.dispose(); + verifyStateError(state, controller); + }); + + test('toString()', () async { + expect(state.toString(), 'SingleMediumState(uninitialized)'); + await state.initialize(_FakeContext()); + expect( + state.toString(), + 'SingleMediumState(uninitializederror: ' + 'Exception: Initialization Error)'); + }); + + test('debugFillProperties() and debugDescribeChildren()', () async { + final identityHash = RegExp(r'#[0-9a-f]{5}'); + + expect( + state.toStringDeep().replaceAll(identityHash, ''), + 'SingleMediumState\n' + ' medium: _FakeSingleMedium(resource: bad-medium, maxDuration:\n' + ' 8760:00:00.000000)\n' + ' size: \n' + ''); + await state.initialize(_FakeContext()); + expect( + state.toStringDeep().replaceAll(identityHash, ''), + 'SingleMediumState\n' + ' medium: _FakeSingleMedium(resource: bad-medium, maxDuration:\n' + ' 8760:00:00.000000)\n' + ' error: Exception: Initialization Error\n' + ' size: \n' + ''); + }); + }); + + group('on good medium', () { + Size size; + _FakeSingleMedium medium; + _FakeSingleMediumController controller; + SingleMediumState state; + + setUp(() { + size = Size(0.0, 0.0); + medium = _FakeSingleMedium('good-medium', size: size); + controller = _FakeSingleMediumController(medium); + state = SingleMediumState(controller); + }); + + void verifyStateInitialized() { + verifyStateInvariants(state, controller); + expect(state.size, size); + expect(state.isInitialized, isTrue); + expect(state.error, isNull); + expect(state.isErroneous, isFalse); + } + + test('constructor asserts on null controller', () { + expect(() => SingleMediumState(null), throwsAssertionError); + }); + + test('the state is properly initialized', () { + verifyStateInitialization(state, controller); + }); + + test('.initialize() gets the size', () async { + await state.initialize(_FakeContext()); + verifyStateInitialized(); + }); + + test('.initialize() sets error with assertion', () async { + await state.initialize(_FakeContext()); + expect(() async => await state.initialize(_FakeContext()), + throwsAssertionError); + }); + + test('.play() runs without error', () async { + await state.play(_FakeContext()); + expect(state.error, isNull); + expect(state.isErroneous, isFalse); + }); + + test('.pause() runs without error', () async { + await state.pause(_FakeContext()); + expect(state.error, isNull); + expect(state.isErroneous, isFalse); + }); + + test('.dispose() runs without error', () async { + await state.dispose(); + expect(state.error, isNull); + expect(state.isErroneous, isFalse); + }); + + test('.build() builds a widget with the expected key', () { + final widget = state.build(_FakeContext()); + expect(widget.key, state.widgetKey); + }); + + test('toString()', () async { + expect(state.toString(), 'SingleMediumState(uninitialized)'); + await state.initialize(_FakeContext()); + expect(state.toString(), 'SingleMediumState(0.0x0.0)'); + }); + + test('debugFillProperties() and debugDescribeChildren()', () async { + final identityHash = RegExp(r'#[0-9a-f]{5}'); + + expect( + state.toStringDeep().replaceAll(identityHash, ''), + 'SingleMediumState\n' + ' medium: _FakeSingleMedium(resource: good-medium, maxDuration:\n' + ' 8760:00:00.000000)\n' + ' size: \n' + ''); + await state.initialize(_FakeContext()); + expect( + state.toStringDeep().replaceAll(identityHash, ''), + 'SingleMediumState\n' + ' medium: _FakeSingleMedium(resource: good-medium, maxDuration:\n' + ' 8760:00:00.000000)\n' + ' size: 0.0x0.0\n' + ''); + }); + }); + }); + + group('SingleMediumStateFactory', () { + test('.good()', () { + final medium = _FakeSingleMedium('bad-medium', size: Size(1.0, 1.0)); + final controller = _FakeSingleMediumController(medium); + final state = SingleMediumStateFactory().good(controller); + verifyStateInitialization(state, controller); + }); + + test('.bad()', () { + final state = SingleMediumStateFactory() + .bad(_FakeSingleMedium('error', size: Size(1.0, 1.0)), 'Error 123'); + expect(state.controller, isNull); + expect(state.widgetKey, isNull); + expect(state.error, 'Error 123'); + expect(state.isErroneous, isTrue); + expect(state.size, isNull); + expect(state.isInitialized, isFalse); + }); + }); +} + +class _FakeContext extends Fake implements BuildContext {} + +class _SingleMediumInfo { + final Size size; + final Exception exception; + final Key widgetKey; + _SingleMediumInfo( + String location, { + this.size, + this.exception, + }) : assert(location != null), + assert(exception != null && size == null || + exception == null && size != null), + widgetKey = GlobalKey(debugLabel: 'widgetKey(${location}'); +} + +class _FakeSingleMedium extends SingleMedium { + final _SingleMediumInfo info; + _FakeSingleMedium( + String location, { + Size size, + Exception exception, + }) : info = _SingleMediumInfo(location, size: size, exception: exception), + super(Uri.parse(location)); +} + +class _FakeSingleMediumController extends Fake + implements SingleMediumController { + @override + _FakeSingleMedium medium; + + @override + Key widgetKey; + + @override + void Function(BuildContext context) onMediumFinished; + + final calls = []; + + _FakeSingleMediumController( + this.medium, { + Key widgetKey, + this.onMediumFinished, + }) : assert(medium != null), + widgetKey = widgetKey ?? GlobalKey(debugLabel: 'mediumKey'); + + Future _errorOr(String name, [T value]) { + calls.add(name); + return medium.info.exception != null + ? Future.error(medium.info.exception) + : Future.value(value); + } + + @override + Future initialize(BuildContext context) => + _errorOr('initialize', medium.info.size); + + @override + Future play(BuildContext context) => _errorOr('play'); + + @override + Future pause(BuildContext context) => _errorOr('pause'); + + @override + Future dispose() => _errorOr('dispose'); + + @override + Widget build(BuildContext context) { + calls.add('build'); + return Container(key: widgetKey); + } +} + +// vim: set foldmethod=syntax foldminlines=3 : diff --git a/test/unit/media_player/multi_medium_controller_test.dart b/test/unit/media_player/multi_medium_controller_test.dart new file mode 100644 index 0000000..fe079d3 --- /dev/null +++ b/test/unit/media_player/multi_medium_controller_test.dart @@ -0,0 +1,504 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart'; +import 'package:lunofono_player/src/media_player/controller_registry.dart' + show ControllerRegistry; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart' + show SingleMediumController, Size; + +import 'package:lunofono_player/src/media_player/multi_medium_controller.dart' + show MultiMediumController; + +// XXX: This test should ideally fake the ControllerRegistry, but we can't do so +// now because of a very obscure problem with the dart compiler/flutter test +// driver. For details please see this issue: +// https://github.com/flutter/flutter/issues/65324 +void main() { + group('MultiMediumController', () { + final registry = ControllerRegistry(); + _registerControllers(registry); + + final audibleMedium = + _FakeAudibleSingleMedium('audible', size: Size(0.0, 0.0)); + final audibleMedium2 = + _FakeAudibleSingleMedium('visualizable', size: Size(10.0, 12.0)); + + final audibleMultiMedium = MultiMedium( + AudibleMultiMediumTrack([audibleMedium, audibleMedium2])); + + final multiMedium = MultiMedium( + AudibleMultiMediumTrack([audibleMedium, audibleMedium2]), + backgroundTrack: _FakeVisualizableBackgroundMultiMediumTrack( + [ + _FakeVisualizableSingleMedium('visualizable1', size: Size(1.0, 1.0)), + _FakeVisualizableSingleMedium('visualizable2', size: Size(2.0, 2.0)), + ], + ), + ); + + group('constructor', () { + group('asserts on', () { + test('null multimedium', () { + expect(() => MultiMediumController(null, registry), + throwsAssertionError); + }); + test('null registry', () { + expect(() => MultiMediumController(audibleMultiMedium, null), + throwsAssertionError); + }); + }); + }); + + test('play cycle works with main track only', () async { + var finished = false; + final controller = MultiMediumController(audibleMultiMedium, registry, + onMediumFinished: (context) => finished = true); + expect(finished, isFalse); + expect(controller.allInitialized, isFalse); + expect(controller.backgroundTrackController, isEmpty); + controller.mainTrackController.mediaState + .forEach((s) => expect(s.controller.asFake.calls, isEmpty)); + + var notifyCalled = false; + final checkInitialized = () { + expect(finished, isFalse); + expect(controller.allInitialized, isTrue); + expect(controller.backgroundTrackController, isEmpty); + expect(controller.mainTrackController.current.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize']); + notifyCalled = true; + }; + controller.addListener(checkInitialized); + + await controller.initialize(_FakeContext()); + expect(notifyCalled, isTrue); + final first = controller.mainTrackController.current; + + controller.removeListener(checkInitialized); + notifyCalled = false; + final updateNotifyCalled = () => notifyCalled = true; + controller.addListener(updateNotifyCalled); + + // First medium finishes + controller.mainTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalled, isFalse); + + controller.removeListener(updateNotifyCalled); + + // Second (and last) medium finishes, onMediumFinished should be called. + controller.mainTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalled, isFalse); + expect(finished, isTrue); + expect(controller.allInitialized, isTrue); + expect(controller.backgroundTrackController, isEmpty); + expect(controller.mainTrackController.current, isNull); + expect(first.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + + await controller.dispose(); + expect(first.controller.asFake.calls, ['initialize', 'play', 'dispose']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + }); + + group('play cycle works with main and background track', () { + bool finished; + int notifyCalls; + + setUp(() { + finished = false; + notifyCalls = 0; + }); + + void updateNotifyCalled() => notifyCalls++; + + Future testInitialize() async { + final controller = MultiMediumController(multiMedium, registry, + onMediumFinished: (context) => finished = true); + expect(finished, isFalse); + expect(controller.allInitialized, isFalse); + expect(controller.backgroundTrackController, isNotEmpty); + controller.mainTrackController.mediaState + .forEach((s) => expect(s.controller.asFake.calls, isEmpty)); + controller.backgroundTrackController.mediaState + .forEach((s) => expect(s.controller.asFake.calls, isEmpty)); + + var notifyCalled = false; + final checkInitialized = () { + expect(finished, isFalse); + expect(controller.allInitialized, isTrue); + expect(controller.backgroundTrackController, isNotEmpty); + expect(controller.mainTrackController.current.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize']); + expect( + controller + .backgroundTrackController.current.controller.asFake.calls, + ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize']); + notifyCalled = true; + }; + controller.addListener(checkInitialized); + + await controller.initialize(_FakeContext()); + expect(notifyCalled, isTrue); + + controller.removeListener(checkInitialized); + return controller; + } + + void testFirstMediaPlayed(MultiMediumController controller) async { + final firstMain = controller.mainTrackController.current; + final firstBack = controller.backgroundTrackController.current; + + // First main medium finishes + controller.mainTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalls, 0); + expect(controller.mainTrackController.current, + same(controller.mainTrackController.last)); + expect(firstMain.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.backgroundTrackController.current, same(firstBack)); + expect(firstBack.controller.asFake.calls, ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize']); + + // First background medium finishes + controller.backgroundTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalls, 0); + expect(controller.mainTrackController.current, + same(controller.mainTrackController.last)); + expect(firstMain.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.backgroundTrackController.current, + same(controller.backgroundTrackController.last)); + expect(firstBack.controller.asFake.calls, ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + } + + test('when background track finishes first', () async { + final controller = await testInitialize(); + final firstMain = controller.mainTrackController.current; + final firstBack = controller.backgroundTrackController.current; + + controller.addListener(updateNotifyCalled); + + await testFirstMediaPlayed(controller); + + // Second background medium finishes + controller.backgroundTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalls, 0); + expect(controller.mainTrackController.current, + same(controller.mainTrackController.last)); + expect(firstMain.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.backgroundTrackController.current, isNull); + expect(firstBack.controller.asFake.calls, ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + + controller.removeListener(updateNotifyCalled); + + // Second (and last) main medium finishes, onMediumFinished should be + // called. + controller.mainTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalls, 0); + expect(finished, isTrue); + expect(controller.allInitialized, isTrue); + expect(controller.backgroundTrackController, isNotEmpty); + expect(controller.mainTrackController.current, isNull); + expect(firstMain.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.backgroundTrackController.current, isNull); + expect(firstBack.controller.asFake.calls, ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + + await controller.dispose(); + expect(firstMain.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect(firstBack.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + }); + + test('when main track finishes first', () async { + final controller = await testInitialize(); + final firstMain = controller.mainTrackController.current; + final firstBack = controller.backgroundTrackController.current; + + controller.addListener(updateNotifyCalled); + + await testFirstMediaPlayed(controller); + + // Second (and last) main medium finishes, onMediumFinished should be + // called. + controller.mainTrackController.current.controller + .onMediumFinished(_FakeContext()); + expect(notifyCalls, 0); + expect(finished, isTrue); + expect(controller.allInitialized, isTrue); + expect(controller.backgroundTrackController, isNotEmpty); + expect(controller.mainTrackController.current, isNull); + expect(firstMain.controller.asFake.calls, ['initialize', 'play']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play']); + expect(controller.backgroundTrackController.current, + controller.backgroundTrackController.last); + expect(firstBack.controller.asFake.calls, ['initialize', 'play']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'pause']); + + controller.removeListener(updateNotifyCalled); + + await controller.dispose(); + expect(firstMain.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect(controller.mainTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect(firstBack.controller.asFake.calls, + ['initialize', 'play', 'dispose']); + expect( + controller.backgroundTrackController.last.controller.asFake.calls, + ['initialize', 'play', 'pause', 'dispose']); + }); + }); + + test('toString()', () { + expect( + MultiMediumController(multiMedium, registry, + onMediumFinished: (context) => null).toString(), + 'MultiMediumController(main: MultiMediumTrackController(audible, ' + 'current: 0, media: 2), ' + 'background: MultiMediumTrackController(visualizable, ' + 'current: 0, media: 2))', + ); + expect( + MultiMediumController(audibleMultiMedium, registry).toString(), + 'MultiMediumController(main: MultiMediumTrackController(audible, ' + 'current: 0, media: 2))', + ); + }); + + test('debugFillProperties() and debugDescribeChildren()', () { + final identityHash = RegExp(r'#[0-9a-f]{5}'); + + expect( + MultiMediumController(multiMedium, registry, + onMediumFinished: (context) => null) + .toStringDeep() + .replaceAll(identityHash, ''), + 'MultiMediumController\n' + ' │ notifies when all media finished\n' + ' │ main:\n' + ' │ MultiMediumTrackController(audible, currentIndex: 0, mediaState.length: 2)\n' + ' │ background:\n' + ' │ MultiMediumTrackController(visualizble, currentIndex: 0, mediaState.length: 2)\n' + ' │\n' + ' ├─main: MultiMediumTrackController\n' + ' │ │ audible\n' + ' │ │ currentIndex: 0\n' + ' │ │ mediaState.length: 2\n' + ' │ │\n' + ' │ ├─0: SingleMediumState\n' + ' │ │ medium: _FakeAudibleSingleMedium(resource: audible, maxDuration:\n' + ' │ │ 8760:00:00.000000)\n' + ' │ │ size: \n' + ' │ │\n' + ' │ └─1: SingleMediumState\n' + ' │ medium: _FakeAudibleSingleMedium(resource: visualizable,\n' + ' │ maxDuration: 8760:00:00.000000)\n' + ' │ size: \n' + ' │\n' + ' └─background: MultiMediumTrackController\n' + ' │ visualizble\n' + ' │ currentIndex: 0\n' + ' │ mediaState.length: 2\n' + ' │\n' + ' ├─0: SingleMediumState\n' + ' │ medium: _FakeVisualizableSingleMedium(resource: visualizable1,\n' + ' │ maxDuration: 8760:00:00.000000)\n' + ' │ size: \n' + ' │\n' + ' └─1: SingleMediumState\n' + ' medium: _FakeVisualizableSingleMedium(resource: visualizable2,\n' + ' maxDuration: 8760:00:00.000000)\n' + ' size: \n' + ''); + + expect( + MultiMediumController(audibleMultiMedium, registry) + .toStringDeep() + .replaceAll(identityHash, ''), + 'MultiMediumController\n' + ' │ main:\n' + ' │ MultiMediumTrackController(audible, currentIndex: 0, mediaState.length: 2)\n' + ' │\n' + ' ├─main: MultiMediumTrackController\n' + ' │ │ audible\n' + ' │ │ currentIndex: 0\n' + ' │ │ mediaState.length: 2\n' + ' │ │\n' + ' │ ├─0: SingleMediumState\n' + ' │ │ medium: _FakeAudibleSingleMedium(resource: audible, maxDuration:\n' + ' │ │ 8760:00:00.000000)\n' + ' │ │ size: \n' + ' │ │\n' + ' │ └─1: SingleMediumState\n' + ' │ medium: _FakeAudibleSingleMedium(resource: visualizable,\n' + ' │ maxDuration: 8760:00:00.000000)\n' + ' │ size: \n' + ' │\n' + ' └─background: MultiMediumTrackController\n' + ' empty\n' + ''); + }); + }); +} + +class _FakeContext extends Fake implements BuildContext {} + +class _SingleMediumInfo { + final Size size; + final Exception exception; + final Key widgetKey; + _SingleMediumInfo( + String location, { + this.size, + this.exception, + }) : assert(location != null), + assert(exception != null && size == null || + exception == null && size != null), + widgetKey = GlobalKey(debugLabel: 'widgetKey(${location}'); +} + +abstract class _FakeSingleMedium extends SingleMedium { + final _SingleMediumInfo info; + _FakeSingleMedium( + String location, { + Size size, + Exception exception, + }) : info = _SingleMediumInfo(location, size: size, exception: exception), + super(Uri.parse(location)); +} + +class _FakeAudibleSingleMedium extends _FakeSingleMedium implements Audible { + _FakeAudibleSingleMedium( + String location, { + Size size, + Exception exception, + }) : super(location, size: size, exception: exception); +} + +class _FakeVisualizableSingleMedium extends _FakeSingleMedium + implements Visualizable { + _FakeVisualizableSingleMedium( + String location, { + Size size, + Exception exception, + }) : super(location, size: size, exception: exception); +} + +class _FakeVisualizableBackgroundMultiMediumTrack + extends VisualizableBackgroundMultiMediumTrack implements Visualizable { + _FakeVisualizableBackgroundMultiMediumTrack(List media) + : super(media); +} + +void _registerControllers(ControllerRegistry registry) { + SingleMediumController createController(SingleMedium medium, + {void Function(BuildContext) onMediumFinished}) { + final fakeMedium = medium as _FakeSingleMedium; + final c = _FakeSingleMediumController(fakeMedium, + widgetKey: fakeMedium.info.widgetKey, + onMediumFinished: onMediumFinished); + return c; + } + + registry.register(_FakeAudibleSingleMedium, createController); + registry.register(_FakeVisualizableSingleMedium, createController); +} + +class _FakeSingleMediumController extends Fake + implements SingleMediumController { + @override + _FakeSingleMedium medium; + + @override + Key widgetKey; + + @override + void Function(BuildContext context) onMediumFinished; + + final calls = []; + + _FakeSingleMediumController( + this.medium, { + Key widgetKey, + this.onMediumFinished, + }) : assert(medium != null), + widgetKey = widgetKey ?? GlobalKey(debugLabel: 'mediumKey'); + + Future _errorOr(String name, [T value]) { + calls.add(name); + return medium.info.exception != null + ? Future.error(medium.info.exception) + : Future.value(value); + } + + @override + Future initialize(BuildContext context) => + _errorOr('initialize', medium.info.size); + + @override + Future play(BuildContext context) => _errorOr('play'); + + @override + Future pause(BuildContext context) => _errorOr('pause'); + + @override + Future dispose() => _errorOr('dispose'); + + @override + Widget build(BuildContext context) { + calls.add('build'); + return Container(key: widgetKey); + } +} + +extension _AsFakeSingleMediumController on SingleMediumController { + _FakeSingleMediumController get asFake => this as _FakeSingleMediumController; +} + +// vim: set foldmethod=syntax foldminlines=3 : diff --git a/test/unit/media_player/multi_medium_player_test.dart b/test/unit/media_player/multi_medium_player_test.dart new file mode 100644 index 0000000..e284297 --- /dev/null +++ b/test/unit/media_player/multi_medium_player_test.dart @@ -0,0 +1,455 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:provider/provider.dart' show ChangeNotifierProvider, Provider; + +import 'package:lunofono_player/src/media_player/multi_medium_controller.dart' + show MultiMediumController, MultiMediumTrackController, SingleMediumState; +import 'package:lunofono_player/src/media_player/media_player_error.dart' + show MediaPlayerError; +import 'package:lunofono_player/src/media_player/multi_medium_player.dart' + show MediaProgressIndicator, MultiMediumPlayer, MultiMediumTrackPlayer; + +import '../../util/foundation.dart' show FakeDiagnosticableMixin; + +void main() { + group('MediaProgressIndicator', () { + testWidgets('constructor asserts if visualizable is null', + (WidgetTester tester) async { + expect(() => MediaProgressIndicator(visualizable: null), + throwsAssertionError); + }); + + Future testInnerWidgets(WidgetTester tester, + {@required IconData icon, @required bool visualizable}) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaProgressIndicator(visualizable: visualizable), + ), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final iconFinder = find.byType(Icon); + expect(iconFinder, findsOneWidget); + expect((tester.widget(iconFinder) as Icon).icon, icon); + } + + testWidgets( + 'if not visualizable has a CircularProgressIndicator and ' + 'a musical note icon', (WidgetTester tester) async { + await testInnerWidgets(tester, + visualizable: false, icon: Icons.music_note); + }); + + testWidgets( + 'if t is visualizable has a CircularProgressIndicator and ' + 'a movie film icon', (WidgetTester tester) async { + await testInnerWidgets(tester, + visualizable: true, icon: Icons.local_movies); + }); + }); + + group('MultiMediumTrackPlayer', () { + final uninitializedState = FakeSingleMediumState( + widgetKey: GlobalKey(debugLabel: 'uninitializedStateKey')); + final errorState = FakeSingleMediumState( + error: Exception('Error'), + widgetKey: GlobalKey(debugLabel: 'errorStateKey')); + final initializedState = FakeSingleMediumState( + size: Size(10.0, 10.0), + widgetKey: GlobalKey(debugLabel: 'initializedStateKey')); + + Future pumpPlayer(WidgetTester tester, + FakeMultiMediumTrackController controller) async => + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ChangeNotifierProvider.value( + value: controller, + child: MultiMediumTrackPlayer(), + ), + ), + ); + + group('error if', () { + Future testError( + WidgetTester tester, + FakeMultiMediumTrackController controller, + ) async { + await pumpPlayer(tester, controller); + + expect(find.byKey(errorState.widgetKey), findsNothing); + expect(find.byKey(initializedState.widgetKey), findsNothing); + expect(find.byKey(uninitializedState.widgetKey), findsNothing); + expect(find.byType(MediaProgressIndicator), findsNothing); + expect(find.byType(RotatedBox), findsNothing); + + expect(find.byType(MediaPlayerError), findsOneWidget); + } + + testWidgets('current is erroneous', (WidgetTester tester) async { + final controller = FakeMultiMediumTrackController( + current: errorState, last: initializedState, isVisualizable: true); + await testError(tester, controller); + }); + + testWidgets('last is erroneous', (WidgetTester tester) async { + final controller = FakeMultiMediumTrackController(last: errorState); + await testError(tester, controller); + }); + }); + + group('progress indicator if initializing a', () { + Future testProgress(WidgetTester tester, bool visualizable) async { + final controller = FakeMultiMediumTrackController( + current: uninitializedState, + last: initializedState, + isVisualizable: visualizable); + + await pumpPlayer(tester, controller); + expect(find.byType(MediaPlayerError), findsNothing); + expect(find.byKey(errorState.widgetKey), findsNothing); + expect(find.byKey(initializedState.widgetKey), findsNothing); + expect(find.byKey(uninitializedState.widgetKey), findsNothing); + expect(find.byType(RotatedBox), findsNothing); + + final progressFinder = find.byType(MediaProgressIndicator); + expect(progressFinder, findsOneWidget); + final widget = tester.widget(progressFinder) as MediaProgressIndicator; + expect(widget.isVisualizable, controller.isVisualizable); + } + + testWidgets('visualizable controller', (WidgetTester tester) async { + await testProgress(tester, true); + }); + testWidgets('non-visualizable controller', (WidgetTester tester) async { + await testProgress(tester, false); + }); + }); + group('initialized shows', () { + Future testPlayer( + WidgetTester tester, + FakeMultiMediumTrackController controller, [ + FakeSingleMediumState playerState, + ]) async { + playerState ??= initializedState; + await pumpPlayer(tester, controller); + expect(find.byType(MediaPlayerError), findsNothing); + expect(find.byKey(errorState.widgetKey), findsNothing); + expect(find.byKey(uninitializedState.widgetKey), findsNothing); + expect(find.byType(MediaProgressIndicator), findsNothing); + + final playerFinder = find.byKey(playerState.widgetKey); + expect(playerFinder, findsOneWidget); + return tester.widget(playerFinder); + } + + group('a Container for non-visualizable', () { + testWidgets('initialized current state', (WidgetTester tester) async { + final controller = FakeMultiMediumTrackController( + current: initializedState, + last: uninitializedState, + isVisualizable: false, + ); + final playerWidget = await testPlayer(tester, controller); + expect(playerWidget, isA()); + expect(find.byType(RotatedBox), findsNothing); + }); + + testWidgets('initialized last state', (WidgetTester tester) async { + final controller = FakeMultiMediumTrackController( + last: initializedState, + isVisualizable: false, + ); + final playerWidget = await testPlayer(tester, controller); + expect(playerWidget, isA()); + expect(find.byType(RotatedBox), findsNothing); + }); + }); + + testWidgets('player for last state', (WidgetTester tester) async { + final controller = FakeMultiMediumTrackController( + last: initializedState, + isVisualizable: true, + ); + await testPlayer(tester, controller); + }); + + testWidgets('no RotatedBox for square media', + (WidgetTester tester) async { + final squareState = FakeSingleMediumState( + size: Size(10.0, 10.0), + widgetKey: GlobalKey(debugLabel: 'squareKey'), + ); + final controller = FakeMultiMediumTrackController( + current: squareState, + last: errorState, + isVisualizable: true, + ); + await testPlayer(tester, controller, squareState); + expect(find.byType(RotatedBox), findsNothing); + }); + + testWidgets('no RotatedBox for portrait media', + (WidgetTester tester) async { + final portraitState = FakeSingleMediumState( + size: Size(100.0, 180.0), + widgetKey: GlobalKey(debugLabel: 'portraitKey'), + ); + final controller = FakeMultiMediumTrackController( + current: portraitState, + last: errorState, + isVisualizable: true, + ); + await testPlayer(tester, controller, portraitState); + expect(find.byType(RotatedBox), findsNothing); + }); + + testWidgets('a RotatedBox for landscape media', + (WidgetTester tester) async { + final landscapeState = FakeSingleMediumState( + size: Size(100.0, 80.0), + widgetKey: GlobalKey(debugLabel: 'landscapeKey'), + ); + final controller = FakeMultiMediumTrackController( + current: landscapeState, + last: errorState, + isVisualizable: true, + ); + await testPlayer(tester, controller, landscapeState); + expect(find.byType(RotatedBox), findsOneWidget); + }); + }); + }); + + group('MultiMediumPlayer', () { + testWidgets('createTrackPlayer() returns a MultiMediumTrackPlayer', + (WidgetTester tester) async { + final player = TestMultiMediumPlayer(); + expect( + player.createTrackPlayerFromSuper(), isA()); + }); + + Future pumpPlayer( + WidgetTester tester, FakeMultiMediumController controller) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ChangeNotifierProvider.value( + value: controller, + child: TestMultiMediumPlayer(), + ), + ), + ); + } + + testWidgets('shows progress if not all initialized', + (WidgetTester tester) async { + final controller = FakeMultiMediumController(allInitialized: false); + + await pumpPlayer(tester, controller); + expect(find.byType(MediaPlayerError), findsNothing); + expect(find.byType(MultiMediumTrackPlayer), findsNothing); + + expect(find.byType(MediaProgressIndicator), findsOneWidget); + }); + + group('shows MultiMediumTrackPlayer', () { + final audibleTrack = FakeMultiMediumTrackController( + current: FakeSingleMediumState(size: Size(0.0, 0.0)), + isVisualizable: false, + ); + final visualTrack = FakeMultiMediumTrackController( + current: FakeSingleMediumState(size: Size(10.0, 10.0)), + isVisualizable: true, + ); + + Future testMainTrackOnly( + WidgetTester tester, FakeMultiMediumTrackController track) async { + final controller = FakeMultiMediumController(mainTrack: track); + + await pumpPlayer(tester, controller); + expect(find.byType(MediaPlayerError), findsNothing); + expect(find.byType(MediaProgressIndicator), findsNothing); + + final stackFinder = find.byType(Stack); + expect(stackFinder, findsOneWidget); + final stack = tester.widget(stackFinder) as Stack; + expect(stack.children.length, 1); + expect(stack.children.first, isA
()); + expect( + find.descendant( + of: find.byWidget(stack.children.first), + matching: find.byType(TestMultiMediumTrackPlayer), + ), + findsOneWidget); + } + + testWidgets('with main track only', (WidgetTester tester) async { + await testMainTrackOnly(tester, audibleTrack); + await testMainTrackOnly(tester, visualTrack); + }); + + Future test2Tracks( + WidgetTester tester, FakeMultiMediumController controller) async { + await pumpPlayer(tester, controller); + expect(find.byType(MediaPlayerError), findsNothing); + expect(find.byType(MediaProgressIndicator), findsNothing); + + expect(find.byType(Center), findsOneWidget); + expect(find.byType(TestMultiMediumTrackPlayer), findsNWidgets(2)); + + final stackFinder = find.byType(Stack); + expect(stackFinder, findsOneWidget); + final stack = tester.widget(stackFinder) as Stack; + expect(stack.children.length, 2); + + // First track should be the visualizable track and centered + expect(stack.children.first, isA
()); + final firstTrackFinder = find.descendant( + of: find.byWidget(stack.children.first), + matching: find.byType(TestMultiMediumTrackPlayer), + ); + expect(firstTrackFinder, findsOneWidget); + final firstController = Provider.of( + tester.element(firstTrackFinder), + listen: false); + expect(firstController, same(visualTrack)); + + // Second track should be the audible one + final secondTrackFinder = find.descendant( + of: find.byWidget(stack.children.last), + matching: find.byType(TestMultiMediumTrackPlayer), + ); + expect(secondTrackFinder, findsOneWidget); + expect( + find.descendant( + of: find.byWidget(stack.children.last), + matching: find.byType(Center), + ), + findsNothing); + final secondController = Provider.of( + tester.element(secondTrackFinder), + listen: false); + expect(secondController, same(audibleTrack)); + } + + testWidgets('with 2 tracks the visualizable track is always the first', + (WidgetTester tester) async { + await test2Tracks( + tester, + FakeMultiMediumController( + mainTrack: audibleTrack, + backgroundTrack: visualTrack, + )); + await test2Tracks( + tester, + FakeMultiMediumController( + mainTrack: visualTrack, + backgroundTrack: audibleTrack, + )); + }); + }); + }); +} + +class FakeSingleMediumState extends Fake + with FakeDiagnosticableMixin + implements SingleMediumState { + @override + dynamic error; + @override + Size size; + @override + final Key widgetKey; + @override + Widget build(BuildContext context) => Container(key: widgetKey); + @override + bool get isInitialized => size != null; + @override + bool get isErroneous => error != null; + @override + String toStringShort() => 'FakeSingleMediumState(' + 'error: $error, ' + 'size: $size, ' + 'widgetKey: $widgetKey' + ')'; + FakeSingleMediumState({ + this.error, + this.size, + Key widgetKey, + }) : widgetKey = widgetKey ?? GlobalKey(debugLabel: 'widgetKey'); +} + +class FakeMultiMediumTrackController extends Fake + with FakeDiagnosticableMixin, ChangeNotifier + implements MultiMediumTrackController { + @override + FakeSingleMediumState current; + @override + FakeSingleMediumState last; + @override + final bool isVisualizable; + @override + bool get isEmpty => current == null && last == null; + @override + bool get isNotEmpty => !isEmpty; + @override + Future dispose() async => super.dispose(); + @override + String toStringShort() => 'FakeMultiMediumTrackController(' + 'current: $current, ' + 'last: $last, ' + 'isVisualizable: $isVisualizable' + ')'; + FakeMultiMediumTrackController({ + this.current, + FakeSingleMediumState last, + this.isVisualizable = false, + }) : last = last ?? current; +} + +class FakeMultiMediumController extends Fake + with FakeDiagnosticableMixin, ChangeNotifier + implements MultiMediumController { + FakeMultiMediumTrackController mainTrack; + FakeMultiMediumTrackController backgroundTrack; + @override + bool allInitialized; + @override + MultiMediumTrackController get mainTrackController => mainTrack; + @override + MultiMediumTrackController get backgroundTrackController => backgroundTrack; + @override + String toStringShort() => 'FakeMultiMediumController(' + 'allInitialized: $allInitialized, ' + 'mainTrack: $mainTrack, ' + 'backgroundTrack: $backgroundTrack' + ')'; + @override + Future dispose() async => super.dispose(); + FakeMultiMediumController({ + FakeMultiMediumTrackController mainTrack, + FakeMultiMediumTrackController backgroundTrack, + bool allInitialized, + }) : allInitialized = allInitialized ?? mainTrack != null, + mainTrack = mainTrack ?? + FakeMultiMediumTrackController(current: FakeSingleMediumState()), + backgroundTrack = backgroundTrack ?? FakeMultiMediumTrackController(); +} + +class TestMultiMediumPlayer extends MultiMediumPlayer { + MultiMediumTrackPlayer createTrackPlayerFromSuper() => + super.createTrackPlayer(); + @override + MultiMediumTrackPlayer createTrackPlayer() => TestMultiMediumTrackPlayer(); +} + +class TestMultiMediumTrackPlayer extends MultiMediumTrackPlayer { + @override + Widget build(BuildContext context) => Container(); +} diff --git a/test/unit/media_player/single_medium_controller/audio_video_player_controller_test.dart b/test/unit/media_player/single_medium_controller/audio_video_player_controller_test.dart new file mode 100644 index 0000000..165ab1e --- /dev/null +++ b/test/unit/media_player/single_medium_controller/audio_video_player_controller_test.dart @@ -0,0 +1,435 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; +import 'package:pedantic/pedantic.dart' show unawaited; + +import 'package:video_player/video_player.dart' as video_player; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show SingleMedium, Audio, Video; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart'; + +import 'single_medium_controller_common.dart'; +import '../../../util/finders.dart' show findSubString; + +FakeVideoInfo globalFakeInfo; + +void main() { + group('VideoPlayerController', () { + final video = Video(Uri.parse('fake-video.avi')); + + TestVideoPlayerController controller; + tearDown(() async => await controller?.dispose()); + + test('constructor asserts on null medium', () { + expect(() => VideoPlayerController(null), throwsAssertionError); + }); + + test('can instantiate a video_player.VideoPlayerController', () async { + controller = TestVideoPlayerController(video, null); + final internalController = controller.testCreateVideoPlayerController(); + expect(internalController, isA()); + expect(internalController.dataSource, video.resource.toString()); + }); + + testWidgets( + 'errors in initialize()', + (WidgetTester tester) async { + var hasFinished = false; + controller = TestVideoPlayerController( + video, + FakeVideoInfo.initError('Initialization error'), + onMediumFinished: (context) => hasFinished = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + await tester.pumpAndSettle(); + expectError(tester, widget); + expect(findSubString(globalFakeInfo.initError), findsOneWidget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + }, + ); + + testWidgets( + 'errors in play()', + (WidgetTester tester) async { + var hasFinished = false; + controller = TestVideoPlayerController( + video, + FakeVideoInfo.playError('Play error'), + onMediumFinished: (context) => hasFinished = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + await tester.pumpAndSettle(); + expectError(tester, widget); + expect(findSubString(globalFakeInfo.playError), findsOneWidget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + }, + ); + + testWidgets( + 'initializes and plays a video until the end', + (WidgetTester tester) async { + final videoInfo = FakeVideoInfo( + Duration(milliseconds: 1000), + Size(10.0, 20.0), + ); + var hasFinished = false; + controller = TestVideoPlayerController( + video, + videoInfo, + onMediumFinished: (context) => hasFinished = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + // Since loading is emulated, in the next frame it should be ready. + // Video should start playing. + final fakeController = + controller.videoPlayerController as FakeVideoPlayerController; + await tester.pump(fakeController.initDelay); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Emulate the video almost ended playing + final seekPosition = + Duration(milliseconds: globalFakeInfo.duration.inMilliseconds - 1); + fakeController.fakeSeekTo(seekPosition); + await tester.pump(seekPosition); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Emulate video completed playing. + fakeController.fakeSeekToEnd(); + + // Advance frames until there are no more changes, so the video should + // have stopped and the onMediumFinished callback should have been + // called. + await tester.pumpAndSettle(); + expectSuccess(tester, widget, findWidget: video_player.VideoPlayer); + expect(hasFinished, true); + expect(controller.value.isPlaying, false); + }, + ); + + testWidgets( + 'initializes and plays a video with a maxDuration until the end', + (WidgetTester tester) async { + final videoInfo = FakeVideoInfo( + Duration(milliseconds: 1000), + Size(10.0, 20.0), + ); + var hasFinished = false; + final limitedVideo = Video( + Uri.parse('fake-video.avi'), + maxDuration: Duration(milliseconds: 600), + ); + controller = TestVideoPlayerController( + limitedVideo, + videoInfo, + onMediumFinished: (context) => hasFinished = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + final fakeController = + controller.videoPlayerController as FakeVideoPlayerController; + await tester.pump(fakeController.initDelay); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Emulate the video playing for almost maxDuration + var seekPosition = + Duration(milliseconds: limitedVideo.maxDuration.inMilliseconds - 1); + fakeController.fakeSeekTo(seekPosition); + await tester.pump(seekPosition); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Now go past the maxDuration + seekPosition = + Duration(milliseconds: limitedVideo.maxDuration.inMilliseconds + 1); + fakeController.fakeSeekTo(seekPosition); + await tester.pump(Duration(milliseconds: 2)); + + // The video should be paused and onMediumFinished called + expectSuccess(tester, widget, findWidget: video_player.VideoPlayer); + expect(hasFinished, true); + expect(controller.value.isPlaying, false); + }, + ); + + testWidgets( + 'initializes and plays a video until the user reacts', + (WidgetTester tester) async { + final videoInfo = FakeVideoInfo( + Duration(milliseconds: 1000), + Size(1024.0, 768.0), + ); + var hasFinished = false; + controller = TestVideoPlayerController( + video, + videoInfo, + onMediumFinished: (context) => hasFinished = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + // Since loading is emulated, in the next frame it should be ready. + // Video should start playing. + final fakeController = + controller.videoPlayerController as FakeVideoPlayerController; + await tester.pump(fakeController.initDelay); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Emulate the video played halfway + final seekPosition = + Duration(milliseconds: globalFakeInfo.duration.inMilliseconds ~/ 2); + fakeController.fakeSeekTo(seekPosition); + await tester.pump(seekPosition); + expectSuccess(tester, widget, + size: videoInfo.size, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, true); + + // Pause the video + // XXX: we have to not await for it because the future is delayed and + // the passage of time is emulated, so it will never called unless we + // pump() some time. + unawaited(controller.pause(null)); + + // Advance the time it takes to pause the video controller, the + // onMediumFinished callback should not have been called since the video + // didn't finished playing and the controller should be paused. + await tester.pump(fakeController.pauseDelay); + expectSuccess(tester, widget, findWidget: video_player.VideoPlayer); + expect(hasFinished, false); + expect(controller.value.isPlaying, false); + + await tester.idle(); // Make sure the pause() timer is done + }, + ); + }); + + group('AudioPlayerController', () { + final audio = Audio(Uri.parse('fake-audio.avi')); + + TestAudioPlayerController controller; + tearDown(() async => await controller?.dispose()); + + test('constructor asserts on null medium', () { + expect(() => AudioPlayerController(null), throwsAssertionError); + }); + + test('is a subclass of VideoPlayerController', () async { + final controller = AudioPlayerController(audio); + expect(controller, isA()); + await controller.dispose(); + }); + + testWidgets('initializes and plays showing an empty container', + (WidgetTester tester) async { + final videoInfo = FakeVideoInfo(Duration(seconds: 1), Size(0.0, 0.0)); + controller = TestAudioPlayerController(audio, videoInfo, + widgetKey: globalSuccessKey); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(controller.value.isPlaying, false); + + // Since loading is emulated, in the next frame it should be ready. + // Video should start playing. + final fakeController = + controller.videoPlayerController as FakeVideoPlayerController; + await tester.pump(fakeController.initDelay); + final foundWidget = expectSuccess(tester, widget, + size: videoInfo.size, findWidget: Container); + expect((foundWidget as Container).child, isNull); + expect(controller.value.isPlaying, true); + }); + }); +} + +class FakeVideoInfo { + Duration duration; + Size size; + String initError; + String playError; + FakeVideoInfo(this.duration, this.size, {this.initError, this.playError}); + FakeVideoInfo.initError(String errorDescription) + : this(null, null, initError: errorDescription); + FakeVideoInfo.playError(String errorDescription) + : this(null, null, playError: errorDescription); +} + +// Only one instance can live at each time +class FakeVideoPlayerController extends Fake + implements video_player.VideoPlayerController { + Duration initDelay = Duration.zero; + Duration playDelay = Duration.zero; + Duration pauseDelay = Duration.zero; + var listeners = []; + @override + final String dataSource; + @override + var value = video_player.VideoPlayerValue.uninitialized(); + @override + int get textureId => 1; + + FakeVideoPlayerController(this.dataSource) : assert(dataSource != null); + + @override + void addListener(VoidCallback listener) => listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => listeners.remove(listener); + + @override + void notifyListeners() { + // do a copy of the list before iterating so we can remove elementes from + // listeners. + for (final listener in listeners.toList()) { + listener(); + } + } + + @override + Future initialize() { + return Future.delayed(initDelay, () { + if (globalFakeInfo.initError != null) { + throw Exception(globalFakeInfo.initError); + } + value = value.copyWith( + duration: globalFakeInfo.duration, + size: globalFakeInfo.size, + ); + expect(listeners, isNotEmpty); + notifyListeners(); + }); + } + + @override + Future play() { + return Future.delayed(playDelay, () { + if (globalFakeInfo.playError != null) { + throw Exception(globalFakeInfo.playError); + } + value = value.copyWith(isPlaying: true); + expect(listeners, isNotEmpty); + notifyListeners(); + }); + } + + @override + Future pause() { + return Future.delayed(pauseDelay, () { + value = value.copyWith(isPlaying: false); + // XXX: we need to call expectSync() because this is called when + // await tester.pump() is called + expectSync(listeners, isNotEmpty); + notifyListeners(); + }); + } + + @override + Future dispose() { + // TODO: error? + return Future.value(); + } + + void fakeSeekTo(Duration position) { + expect(position, lessThanOrEqualTo(value.duration)); + if (position == value.duration) { + value = value.copyWith(isPlaying: false); + } + value = value.copyWith(position: position); + expect(listeners, isNotEmpty); + notifyListeners(); + } + + void fakeSeekToEnd() => fakeSeekTo(value.duration); +} + +class TestVideoPlayerController extends VideoPlayerController { + TestVideoPlayerController( + SingleMedium medium, + FakeVideoInfo info, { + void Function(BuildContext) onMediumFinished, + Key widgetKey, + }) : super(medium, onMediumFinished: onMediumFinished, widgetKey: widgetKey) { + globalFakeInfo = info; + } + + video_player.VideoPlayerValue get value => videoPlayerController.value; + + @override + video_player.VideoPlayerController createVideoPlayerController() { + return FakeVideoPlayerController(medium.toString()); + } + + video_player.VideoPlayerController testCreateVideoPlayerController() { + return super.createVideoPlayerController(); + } +} + +class TestAudioPlayerController extends AudioPlayerController { + TestAudioPlayerController( + SingleMedium medium, + FakeVideoInfo info, { + void Function(BuildContext) onMediumFinished, + Key widgetKey, + }) : super(medium, onMediumFinished: onMediumFinished, widgetKey: widgetKey) { + globalFakeInfo = info; + } + + video_player.VideoPlayerValue get value => videoPlayerController.value; + + @override + video_player.VideoPlayerController createVideoPlayerController() { + return FakeVideoPlayerController(medium.resource.toString()); + } +} diff --git a/test/unit/media_player/single_medium_controller/image_player_controller_test.dart b/test/unit/media_player/single_medium_controller/image_player_controller_test.dart new file mode 100644 index 0000000..975a4bb --- /dev/null +++ b/test/unit/media_player/single_medium_controller/image_player_controller_test.dart @@ -0,0 +1,255 @@ +@Tags(['unit', 'player']) + +import 'dart:ui' as ui; +import 'dart:typed_data' show Uint8List; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_bundle/lunofono_bundle.dart' as bundle show Image; +import 'package:lunofono_player/src/media_player/single_medium_controller.dart'; + +import 'single_medium_controller_common.dart'; + +void main() { + group('ImagePlayerController', () { + ImagePlayerController controller; + tearDown(() async => await controller?.dispose()); + + test('constructor asserts on null location', () { + expect(() => ImagePlayerController(null), throwsAssertionError); + }); + + testWidgets( + 'initializes with error', + (WidgetTester tester) async { + controller = ImagePlayerController( + bundle.Image(Uri.parse('i-dont-exist.png')), + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + await tester.pumpWidget(widget); + expectLoading(tester, widget); + + await tester.pumpAndSettle(); + expectError(tester, widget); + }, + ); + + testWidgets( + 'initialization of 10x10 asset without onMediumFinished', + (WidgetTester tester) async { + controller = ImagePlayerController( + bundle.Image(Uri.parse('assets/10x10-red.png')), + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + await tester.pumpWidget(widget); + expectLoading(tester, widget); + + await tester.runAsync(() async => await undeadlockAsync()); // XXX!!! + await tester.pumpAndSettle(); + expectSuccess(tester, widget, + size: Size(10.0, 10.0), findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + }, + ); + + testWidgets( + 'onMediumFinished is not called plays forever (forever is 10 days)', + (WidgetTester tester) async { + var hasStopped = false; + controller = ImagePlayerController( + bundle.Image(Uri.parse('assets/10x10-red.png')), + onMediumFinished: (context) => hasStopped = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasStopped, false); + + await tester.runAsync(() async => await undeadlockAsync()); // XXX!!! + await tester.pumpAndSettle(); + final size = Size(10.0, 10.0); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // pause() has not effect when there is no maxDuration. + await controller.pause(null); + + await tester.pump(Duration(days: 10)); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + }, + ); + + testWidgets( + 'onMediumFinished is called if maxDuration is set', + (WidgetTester tester) async { + var hasStopped = false; + final image = bundle.Image( + Uri.parse('assets/10x10-red.png'), + maxDuration: Duration(seconds: 1), + ); + controller = ImagePlayerController( + image, + onMediumFinished: (context) => hasStopped = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasStopped, false); + + await tester.runAsync(() async => await undeadlockAsync()); // XXX!!! + await tester.pumpAndSettle(); + final size = Size(10.0, 10.0); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // Half the time passes, it should be still playing + await tester.pump(image.maxDuration ~/ 2); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // Now all the time passed, so onMediumFinished should have been called + await tester.pump(image.maxDuration ~/ 2); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, true); + }, + ); + + testWidgets( + 'onMediumFinished pause() works when maxDuration is set', + (WidgetTester tester) async { + var hasStopped = false; + final image = bundle.Image( + Uri.parse('assets/10x10-red.png'), + maxDuration: Duration(seconds: 1), + ); + controller = ImagePlayerController( + image, + onMediumFinished: (context) => hasStopped = true, + widgetKey: globalSuccessKey, + ); + final widget = TestWidget(controller); + + await tester.pumpWidget(widget); + expectLoading(tester, widget); + expect(hasStopped, false); + + await tester.runAsync(() async => await undeadlockAsync()); // XXX!!! + await tester.pumpAndSettle(); + final size = Size(10.0, 10.0); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // Half the time passes, it should be still playing + await tester.pump(image.maxDuration ~/ 2); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // Now we pause and let a day go by and it should be still playing + await controller.pause(null); + await tester.pump(Duration(days: 1)); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, false); + + // Now we resume playing and let the final half of the maxDuration pass + // and it should have finished + await controller.play(null); + await tester.pump(image.maxDuration ~/ 2); + expectSuccess(tester, widget, size: size, findWidget: Image); + expect(find.byWidget(controller.image), findsOneWidget); + expect(hasStopped, true); + }, + ); + }); +} + +// XXX: This is an awful hack, for some reason this fixes a deadlock in the +// pumpAndSettle() call. See this issue for details: +// https://github.com/flutter/flutter/issues/64564 +Future undeadlockAsync() async { + const kTransparentImage = [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE + ]; + final codec = + await ui.instantiateImageCodec(Uint8List.fromList(kTransparentImage)); + return await codec.getNextFrame(); +} diff --git a/test/unit/media_player/single_medium_controller/single_medium_controller_common.dart b/test/unit/media_player/single_medium_controller/single_medium_controller_common.dart new file mode 100644 index 0000000..e0454c1 --- /dev/null +++ b/test/unit/media_player/single_medium_controller/single_medium_controller_common.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_player/src/media_player/single_medium_controller.dart'; + +import '../../../util/test_asset_bundle.dart' show TestAssetBundle; + +class NoCheckSize extends Size { + const NoCheckSize() : super(0.0, 0.0); +} + +final globalSuccessKey = GlobalKey(debugLabel: 'successKey'); + +Size globalSize; + +void expectLoading(WidgetTester tester, TestWidget widget) { + expect(find.byKey(widget.loadingKey), findsOneWidget); + expect(find.byKey(widget.errorKey), findsNothing); + expect(find.byKey(globalSuccessKey), findsNothing); + expect(globalSize, null); +} + +void expectError(WidgetTester tester, TestWidget widget) { + expect(find.byKey(widget.errorKey), findsOneWidget); + expect(find.byKey(widget.loadingKey), findsNothing); + expect(find.byKey(globalSuccessKey), findsNothing); + expect(globalSize, null); +} + +Widget expectSuccess(WidgetTester tester, TestWidget widget, + {Size size = const NoCheckSize(), Type findWidget}) { + expect(find.byKey(globalSuccessKey), findsOneWidget); + expect(find.byKey(widget.loadingKey), findsNothing); + expect(find.byKey(widget.errorKey), findsNothing); + if (size is! NoCheckSize) { + expect(globalSize, size); + } + if (findWidget != null) { + final foundWidget = find.byType(findWidget); + expect(foundWidget, findsOneWidget); + return tester.firstWidget(foundWidget); + } + return null; +} + +class TestWidget extends StatelessWidget { + final Key errorKey; + final Key loadingKey; + final SingleMediumController controller; + final AssetBundle bundle; + TestWidget(this.controller, {AssetBundle bundle}) + : assert(controller != null), + errorKey = GlobalKey(debugLabel: 'errorKey'), + loadingKey = GlobalKey(debugLabel: 'loadingKey'), + bundle = bundle ?? TestAssetBundle() { + globalSize = null; + } + + Future _initializeAndPlay(BuildContext context) async { + final size = await controller.initialize(context); + globalSize = size; + await controller.play(context); + return size; + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: bundle, + // Needed so we pass the context with the overridden DefaultAssetBundle + child: Builder( + builder: (context) => FutureBuilder( + future: _initializeAndPlay(context), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return controller.build(context); + } + if (snapshot.hasError) { + return Text( + 'Error loading ${controller.medium.resource}: ${snapshot.error}', + key: errorKey, + ); + } + return Text('Loading ${controller.medium.resource}...', + key: loadingKey); + }, + ), + ), + ), + ); + } +} diff --git a/test/unit/media_player_test.dart b/test/unit/media_player_test.dart new file mode 100644 index 0000000..44982bb --- /dev/null +++ b/test/unit/media_player_test.dart @@ -0,0 +1,942 @@ +@Tags(['unit', 'player']) + +import 'dart:async' show Timer, Completer; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart'; +import 'package:lunofono_player/src/media_player/controller_registry.dart'; +import 'package:lunofono_player/src/media_player/media_player_error.dart'; +import 'package:lunofono_player/src/media_player.dart'; + +import '../util/finders.dart' show findSubString; + +// XXX: This test should ideally fake the ControllerRegistry, but we can't do so +// now because of a very obscure problem with the dart compiler/flutter test +// driver. For details please see this issue: +// https://github.com/flutter/flutter/issues/65324 +void main() { + group('MediaPlayer', () { + MediaPlayerTester playerTester; + + tearDown(() => playerTester?.dispose()); + + test('constructor asserts on null media', () { + expect(() => MediaPlayer(multimedium: null), throwsAssertionError); + }); + + Future testUnregisteredMedium( + WidgetTester tester, FakeSingleMedium medium) async { + // TODO: Second medium in a track is unregistered + final player = MediaPlayer( + multimedium: MultiMedium.fromSingleMedium(medium), + ); + + // Since controller creation is done asynchronously, first the progress + // indicator should always be shown. + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: player)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // If we pump again, the controller creation should have failed. + await tester.pump(); + expect(find.byType(MediaPlayerError), findsOneWidget); + expect(findSubString('Unsupported type'), findsOneWidget); + } + + testWidgets( + 'shows a MediaPlayerErrors if audible controller is not registered', + (WidgetTester tester) async { + final medium = FakeAudibleSingleMedium( + 'unregisteredAudibleMedium', + size: Size(0.0, 0.0), + ); + await testUnregisteredMedium(tester, medium); + }); + + testWidgets( + 'shows a MediaPlayerErrors if visualizable controller is not registered', + (WidgetTester tester) async { + final medium = FakeVisualizableSingleMedium( + 'unregisteredVisualizableMedium', + size: Size(10.0, 10.0), + ); + await testUnregisteredMedium(tester, medium); + }); + + Future testInitializationError( + WidgetTester tester, FakeSingleMedium medium) async { + // TODO: Second medium in a track is unregistered + playerTester = MediaPlayerTester(tester, medium); + + await playerTester.testInitializationDone(); + playerTester.expectErrorWidget(); + playerTester.expectPlayingStatus(finished: false); + expect(findSubString(medium.info.exception.toString()), findsOneWidget); + } + + testWidgets('initializes audible with error', (WidgetTester tester) async { + final exception = Exception('Initialization Error'); + final medium = FakeAudibleSingleMedium('exceptionAudibleMedium', + exception: exception); + await testInitializationError(tester, medium); + }); + + testWidgets('initializes visualizable with error', + (WidgetTester tester) async { + final exception = Exception('Initialization Error'); + final medium = FakeVisualizableSingleMedium('exceptionVisualizableMedium', + exception: exception); + await testInitializationError(tester, medium); + }); + + testWidgets('player should not be rotated for square visualizable media', + (WidgetTester tester) async { + final notRotatedSquareMedium = FakeVisualizableSingleMedium( + 'notRotatedSquareMedium', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + playerTester = MediaPlayerTester(tester, notRotatedSquareMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(rotated: false); + playerTester.expectPlayingStatus(finished: false); + }); + + testWidgets('player should not be rotated for portrait visualizable media', + (WidgetTester tester) async { + final notRotatedPortraitMedium = FakeVisualizableSingleMedium( + 'notRotatedPortraitMedium', + size: Size(10.0, 20.0), + duration: Duration(seconds: 1), + ); + playerTester = MediaPlayerTester(tester, notRotatedPortraitMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(rotated: false); + playerTester.expectPlayingStatus(finished: false); + }); + + testWidgets('player should be rotated for landscape visualizable media', + (WidgetTester tester) async { + final rotatedLandscapeMedium = FakeVisualizableSingleMedium( + 'rotatedLandscapeMedium', + size: Size(20.0, 10.0), + duration: Duration(seconds: 1), + ); + playerTester = MediaPlayerTester(tester, rotatedLandscapeMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(rotated: true); + playerTester.expectPlayingStatus(finished: false); + }); + + Future testPlayMediaUntilEnd( + WidgetTester tester, FakeSingleMedium medium) async { + playerTester = MediaPlayerTester(tester, medium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Wait until half of the media was played, it should keep playing + await tester.pump(medium.info.duration ~/ 2); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Wait until the media stops playing by itself + await tester.pump(medium.info.duration ~/ 2); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: true); + } + + testWidgets('plays limited audible media until the end', + (WidgetTester tester) async { + final medium = FakeAudibleSingleMedium( + 'limitedAudibleMedium', + size: Size(0.0, 0.0), + duration: Duration(seconds: 1), + ); + await testPlayMediaUntilEnd(tester, medium); + }); + + testWidgets('plays limited visualizable media until the end', + (WidgetTester tester) async { + final medium = FakeVisualizableSingleMedium( + 'limitedVisualizableMedium', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + await testPlayMediaUntilEnd(tester, medium); + }); + + testWidgets('plays unlimited media forever(ish, 10 days)', + (WidgetTester tester) async { + final unlimitedMedium = FakeVisualizableSingleMedium('unlimitedMedium', + size: Size(10.0, 10.0)); + playerTester = MediaPlayerTester(tester, unlimitedMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Wait until half of the media was played, it should keep playing + await tester.pump(Duration(days: 10)); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + }); + + testWidgets('tap stops while initializing', (WidgetTester tester) async { + final tapInitMedium = FakeVisualizableSingleMedium( + 'tapInitMedium', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + initDelay: Duration(milliseconds: 100), + ); + playerTester = MediaPlayerTester(tester, tapInitMedium); + + // The player should be initializing + await tester.pumpWidget( + playerTester.player, tapInitMedium.info.initDelay ~/ 2); + playerTester.expectInitializationWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Tap and the reaction should reach the controller + final widgetToTap = find.byType(CircularProgressIndicator); + expect(widgetToTap, findsOneWidget); + await tester.tap(widgetToTap); + await tester.pump(); + playerTester.expectInitializationWidget(); + playerTester.expectPlayingStatus( + finished: false, stoppedTimes: 1, paused: true); + }); + + testWidgets('tap stops while playing', (WidgetTester tester) async { + final tapPlayMedium = FakeVisualizableSingleMedium( + 'PlaynitMedium', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + playerTester = MediaPlayerTester(tester, tapPlayMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Wait until half of the media was played, it should keep playing + await tester.pump(tapPlayMedium.info.duration ~/ 2); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Tap and the player should stop + var widgetToTap = find.byKey(tapPlayMedium.info.widgetKey); + expect(widgetToTap, findsOneWidget); + await tester.tap(widgetToTap); + await tester.pump(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus( + finished: false, stoppedTimes: 1, paused: true); + + // Tap again should do nothing new (but to call the onMediaStopped + // callback again). + widgetToTap = find.byKey(tapPlayMedium.info.widgetKey); + expect(widgetToTap, findsOneWidget); + await tester.tap(widgetToTap); + await tester.pump(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus( + finished: false, stoppedTimes: 2, paused: true); + }); + + testWidgets('tap does nothing when playing is done', + (WidgetTester tester) async { + final tapPlayDoneMedium = FakeVisualizableSingleMedium( + 'tapPlayDoneMedium', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + playerTester = MediaPlayerTester(tester, tapPlayDoneMedium); + + await playerTester.testInitializationDone(); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: false); + + // Wait until the media stops playing by itself + await tester.pump(tapPlayDoneMedium.info.duration); + playerTester.expectPlayerWidget(); + playerTester.expectPlayingStatus(finished: true); + + // Tap again should do nothing but to get a reaction + final widgetToTap = find.byKey(tapPlayDoneMedium.info.widgetKey); + expect(widgetToTap, findsOneWidget); + await tester.tap(widgetToTap); + await tester.pump(); + playerTester.expectPlayerWidget(); + // In this case it should not be paused, because pause() is only called if + // the medium didn't finished by itself. + playerTester.expectPlayingStatus(finished: true, stoppedTimes: 2); + }); + + testWidgets('initialization of visualizable multi-medium mainTrack', + (WidgetTester tester) async { + final medium1 = FakeAudibleVisualizableSingleMedium( + 'medium1(audible, visualizable)', + size: Size(20.0, 10.0), + duration: Duration(seconds: 7), + initDelay: Duration(seconds: 2), + ); + + final medium2 = FakeVisualizableSingleMedium( + 'medium2(visualizable)', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + initDelay: Duration(seconds: 1), + ); + + final medium3 = FakeVisualizableSingleMedium( + 'medium3(visualizable)', + size: Size(10.0, 20.0), + duration: Duration(seconds: 1), + initDelay: Duration(seconds: 3), + ); + + final medium = MultiMedium( + VisualizableMultiMediumTrack([medium1, medium2, medium3]), + ); + + playerTester = MediaPlayerTester(tester, medium); + + final mainTrack = List.from(medium.mainTrack.media + .map((m) => m as FakeSingleMedium)); + + // The first time the player is pumped, it should be initializing. + await tester.pumpWidget(playerTester.player); + playerTester.expectMultiTrackIsInitialzing(); + + // Initialization (since it's done in parallel) should take the time it + // takes to the medium with the maximum initialization time. + // We'll test this by first waiting for the medium that has the shorter + // initialization time, the widget should be still initializing. Then wait + // halfway to the maximum initialization, it should be still initializing, + // then one millisecond before the maximum initialization, still + // initializing. Then after the maximum it should be done initializing. + final minInit = mainTrack + .map((m) => m.info.initDelay) + .reduce((d1, d2) => d1 < d2 ? d1 : d2); + final maxInit = mainTrack + .map((m) => m.info.initDelay) + .reduce((d1, d2) => d1 > d2 ? d1 : d2); + + var left = Duration(milliseconds: maxInit.inMilliseconds); + final pump = (Duration t) async { + await tester.pump(t); + left -= t; + }; + + await pump(minInit); + playerTester.expectMultiTrackIsInitialzing(); + await pump(left ~/ 2); + playerTester.expectMultiTrackIsInitialzing(); + await pump(left - Duration(milliseconds: 1)); + playerTester.expectMultiTrackIsInitialzing(); + await pump(Duration(milliseconds: 1)); + + // Now the first medium should have started playing and not be finished + playerTester.expectPlayerWidget(mainMediumIndex: 0); + playerTester.expectPlayingStatus(mainMediumIndex: 0, finished: false); + expect(playerTester.mainControllers.first.isPlaying, isTrue); + + // And the following media should have not started nor finished + playerTester.mainTrackIndexes.skip(1).forEach((n) => playerTester + .expectPlayingStatus(mainMediumIndex: n, finished: false)); + playerTester.mainTrackIndexes.skip(1).forEach( + (n) => expect(playerTester.mainControllers[n].isPlaying, isFalse)); + }); + + testWidgets('plays a audible multi-medium mainTrack until the end', + (WidgetTester tester) async { + final medium1 = FakeAudibleSingleMedium( + 'medium1(audible)', + size: Size(0.0, 0.0), + duration: Duration(seconds: 1), + ); + + final medium2 = FakeAudibleVisualizableSingleMedium( + 'medium2(audible, visualizable)', + size: Size(10.0, 20.0), + duration: Duration(seconds: 7), + ); + + final multiAudibleMainTrackMedium = MultiMedium( + AudibleMultiMediumTrack([medium1, medium2]), + ); + + playerTester = MediaPlayerTester(tester, multiAudibleMainTrackMedium); + await playerTester.testMultiTrackPlay(untilFinished: true); + }); + + testWidgets('plays a visualizable multi-medium mainTrack until the end', + (WidgetTester tester) async { + final medium1 = FakeAudibleVisualizableSingleMedium( + 'medium1(audible, visualizable)', + size: Size(20.0, 10.0), + duration: Duration(seconds: 7), + ); + + final medium2 = FakeVisualizableSingleMedium( + 'medium2(visualizable)', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + + final medium3 = FakeVisualizableSingleMedium( + 'medium3(visualizable)', + size: Size(10.0, 20.0), + duration: Duration(seconds: 1), + ); + + final multiVisualizableMainTrackMedium = MultiMedium( + VisualizableMultiMediumTrack([medium1, medium2, medium3]), + ); + + playerTester = + MediaPlayerTester(tester, multiVisualizableMainTrackMedium); + await playerTester.testMultiTrackPlay(untilFinished: true); + }); + + testWidgets( + 'plays an audible multi-medium mainTrack with the same medium 2 times', + (WidgetTester tester) async { + final medium1 = FakeAudibleSingleMedium( + 'medium1(audible)', + size: Size(0.0, 0.0), + duration: Duration(seconds: 1), + ); + + final medium = MultiMedium( + AudibleMultiMediumTrack([medium1, medium1]), + ); + + playerTester = MediaPlayerTester(tester, medium); + await playerTester.testMultiTrackPlay(untilFinished: true); + }); + + testWidgets( + 'plays a visualizable multi-medium mainTrack with the same medium 2 times', + (WidgetTester tester) async { + final medium1 = FakeVisualizableSingleMedium( + 'medium1(audible)', + size: Size(0.0, 0.0), + duration: Duration(seconds: 1), + ); + + final medium = MultiMedium( + VisualizableMultiMediumTrack([medium1, medium1]), + ); + + playerTester = MediaPlayerTester(tester, medium); + await playerTester.testMultiTrackPlay(untilFinished: true); + }); + + testWidgets('tap stops while playing the second medium', + (WidgetTester tester) async { + final medium1 = FakeAudibleVisualizableSingleMedium( + 'medium1(audible, visualizable)', + size: Size(20.0, 10.0), + duration: Duration(seconds: 7), + ); + + final medium2 = FakeVisualizableSingleMedium( + 'medium2(visualizable)', + size: Size(10.0, 10.0), + duration: Duration(seconds: 1), + ); + + final medium3 = FakeVisualizableSingleMedium( + 'medium3(visualizable)', + size: Size(10.0, 20.0), + duration: Duration(seconds: 1), + ); + + final multiVisualizableMainTrackMedium = MultiMedium( + VisualizableMultiMediumTrack([medium1, medium2, medium3]), + ); + + playerTester = + MediaPlayerTester(tester, multiVisualizableMainTrackMedium); + await playerTester.testMultiTrackPlay(untilMainIndex: 1); // plays medium1 + + // Now medim2 (index 1) should be playing (and the only one), prev media + // finished and next media not finished + playerTester.expectPlayerWidget(mainMediumIndex: 1); + playerTester.expectPlayingStatus( + mainMediumIndex: 0, finished: true, stoppedTimes: 0); + expect(playerTester.mainControllers[0].isPlaying, isFalse); + playerTester.expectPlayingStatus(mainMediumIndex: 1, finished: false); + expect(playerTester.mainControllers[1].isPlaying, isTrue); + playerTester.expectPlayingStatus(mainMediumIndex: 2, finished: false); + expect(playerTester.mainControllers[2].isPlaying, isFalse); + + // Play medium2 halfway + await tester.pump(medium2.info.duration ~/ 2); + playerTester.expectPlayerWidget(mainMediumIndex: 1); + playerTester.expectPlayingStatus( + mainMediumIndex: 0, finished: true, stoppedTimes: 0); + playerTester.expectPlayingStatus(mainMediumIndex: 1, finished: false); + expect(playerTester.mainControllers[1].isPlaying, isTrue); + playerTester.expectPlayingStatus(mainMediumIndex: 2, finished: false); + + // Tap and the player should stop + var widgetToTap = find.byKey(medium2.info.widgetKey); + expect(widgetToTap, findsOneWidget); + await tester.tap(widgetToTap); + await tester.pump(); + playerTester.expectPlayerWidget(mainMediumIndex: 1); + // medium1 should be finished and the MediaPlayer should have stopped + playerTester.expectPlayingStatus( + mainMediumIndex: 0, finished: true, stoppedTimes: 1); + // medium2 and medium3 should NOT be finished (and the MediaPlayer should + // have stopped). medium2's controller should have received the stop + // reaction. + playerTester.expectPlayingStatus( + mainMediumIndex: 1, finished: false, stoppedTimes: 1, paused: true); + playerTester.expectPlayingStatus( + mainMediumIndex: 2, finished: false, stoppedTimes: 1); + }); + }); +} + +/// A [MediaPlayer] tester. +/// +/// This class provide 3 main family of useful methods: +/// +/// * testXxx(): test a common part of the lifecycle, awaiting to +/// tester.pump*(). +/// +/// * expectXxxWidget(): uses several expect() calls to verify what kind of +/// widget is being shown. +/// +/// * expectPlayingStatus(): checks the player status (if it is playing or not, +/// if there were reactions... +class MediaPlayerTester { + // Taken by the constructor + final WidgetTester tester; + final MultiMedium medium; + + // Automatically initialized + final ControllerRegistry registry = ControllerRegistry(); + final mainControllers = []; + Widget player; + var playerHasStoppedTimes = 0; + + // Constant + final playerKey = GlobalKey(debugLabel: 'playerKey'); + + MediaPlayerTester(this.tester, Medium medium) + : assert(tester != null), + assert(medium != null), + assert(medium is SingleMedium || medium is MultiMedium), + medium = medium is SingleMedium + ? MultiMedium.fromSingleMedium(medium) + : medium as MultiMedium { + _registerControllers(); + player = _createPlayer(); + } + + void dispose() { + for (final c in mainControllers) { + c.dispose(); + } + } + + void _registerControllers() { + SingleMediumController createController(SingleMedium medium, + {void Function(BuildContext) onMediumFinished}) { + final fakeMedium = medium as FakeSingleMedium; + final c = FakeSingleMediumController( + fakeMedium, onMediumFinished, fakeMedium.info.widgetKey); + mainControllers.add(c); + return c; + } + + registry.register(FakeAudibleSingleMedium, createController); + registry.register(FakeVisualizableSingleMedium, createController); + registry.register(FakeAudibleVisualizableSingleMedium, createController); + } + + Widget _createPlayer() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaPlayer( + multimedium: medium, + backgroundColor: Colors.red, + onMediaStopped: (context) { + playerHasStoppedTimes++; + }, + registry: registry, + key: playerKey, + ), + ); + } + + FakeSingleMedium getMainMediumAt(int index) { + assert(index != null && index >= 0); + return medium.mainTrack.media[index] as FakeSingleMedium; + } + + FakeSingleMediumController getMainControllerAt(int index) { + assert(index != null && index >= 0); + return index < mainControllers.length ? mainControllers[index] : null; + } + + Future testInitializationDone({int mainMediumIndex = 0}) async { + final currentMedium = getMainMediumAt(mainMediumIndex); + // The player should be initializing + await tester.pumpWidget(player); + expectInitializationWidget(mainMediumIndex: mainMediumIndex); + expectPlayingStatus(mainMediumIndex: mainMediumIndex, finished: false); + + // After half of the initialization time, it keeps initializing + await tester.pump(currentMedium.info.initDelay ~/ 2); + expectInitializationWidget(mainMediumIndex: mainMediumIndex); + expectPlayingStatus(mainMediumIndex: mainMediumIndex, finished: false); + + // Wait until it is initialized and it should show the player or the + // exception + await tester.pump(currentMedium.info.initDelay ~/ 2); + } + + Iterable get mainTrackIndexes => + Iterable.generate(medium.mainTrack.media.length); + + /// Test that all media in the mainTrack is played, until it finished or some + /// index. + /// + /// If [untilMainIndex] is used, it will play until the media with + /// [untilMainIndex] exclusively (the medium at that index won't be played, it + /// will be left when the previous medium was finished playing. + /// + /// Either [untilMainIndex] or [untilFinished] must be specified. If + /// [untilFinished] is used, it must be true and it is an alias for + /// [untilMainIndex] = mainTrack.media.length. + Future testMultiTrackPlay( + {int untilMainIndex, bool untilFinished}) async { + final mainTrack = List.from(medium.mainTrack.media + .map((m) => m as FakeSingleMedium)); + + assert(untilMainIndex != null || untilFinished == true); + if (untilFinished == true) { + untilMainIndex = mainTrack.length; + } + + // We pump the player widget, it should be initializing + await tester.pumpWidget(player); + expectMultiTrackIsInitialzing(); + + // Now pump the time it takes the medium with the maximum initialization + // time (as initialization is done in parallel, all should have finished + // initializing after that). + final maxInit = mainTrack + .map((m) => m.info.initDelay) + .reduce((d1, d2) => d1 > d2 ? d1 : d2); + await tester.pump(maxInit); + + // Now the first medium should have started playing (and only the first + // medium) but nothing should have finished yet. + expect(mainControllers.first.isPlaying, isTrue); + mainTrackIndexes.forEach( + (n) => expectPlayingStatus(mainMediumIndex: n, finished: false)); + mainTrackIndexes + .skip(1) + .forEach((n) => expect(mainControllers[n].isPlaying, isFalse)); + + // Now the first media should be playing and we check all media plays in + // sequence. + for (var currentIndex = 0; currentIndex < untilMainIndex; currentIndex++) { + final current = mainTrack[currentIndex]; + final prev = mainTrackIndexes.take(currentIndex); + final next = mainTrackIndexes.skip(currentIndex + 1); + + // We start when the current medium started playing, so the widget should + // be showing + expectPlayerWidget(mainMediumIndex: currentIndex); + // The previous media should have finished and not be playing. + prev.forEach((p) => expectPlayingStatus( + mainMediumIndex: p, finished: true, stoppedTimes: 0)); + prev.forEach((n) => expect(mainControllers[n].isPlaying, isFalse)); + // The current should be playing and not finished. + expectPlayingStatus(mainMediumIndex: currentIndex, finished: false); + expect(mainControllers[currentIndex].isPlaying, isTrue); + // And the following should be neither playing nor finished. + next.forEach( + (n) => expectPlayingStatus(mainMediumIndex: n, finished: false)); + next.forEach((n) => expect(mainControllers[n].isPlaying, isFalse)); + + // Wait until the current finishes playing + await tester.pump(current.info.duration); + } + + // Only check for the last conditions + if (untilMainIndex == mainTrack.length) { + // After all media was played, the last medium should still be shown + expectPlayerWidget(mainMediumIndex: mainTrack.length - 1); + // All the media should be finished, the MediaPlayer should be stopped. + mainTrack.asMap().keys.forEach((m) => expectPlayingStatus( + mainMediumIndex: m, finished: true, stoppedTimes: 1)); + } + } + + void _expectMediumWidget(FakeSingleMedium expectedMedium) { + for (final m in medium.mainTrack.media) { + expect(find.byKey((m as FakeSingleMedium).info.widgetKey), + identical(m, expectedMedium) ? findsOneWidget : findsNothing); + } + } + + void expectMultiTrackIsInitialzing() { + expectInitializationWidget(mainMediumIndex: 0); + // All media shouldn't be finished yet + mainTrackIndexes.forEach( + (n) => expectPlayingStatus(mainMediumIndex: n, finished: false)); + // and shouldn't be playing + mainTrackIndexes + .forEach((n) => expect(mainControllers[n].isPlaying, isFalse)); + } + + void expectInitializationWidget({int mainMediumIndex = 0}) { + final currentMedium = getMainMediumAt(mainMediumIndex); + final currentController = getMainControllerAt(mainMediumIndex); + assert(currentMedium != null); + assert(currentController != null); + assert(currentController.medium != null); + expect(currentController.medium.resource, currentMedium.resource); + expect(find.byKey(playerKey), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(MediaPlayerError), findsNothing); + expect( + findSubString(currentMedium.info.exception.toString()), findsNothing); + expect(find.byType(RotatedBox), findsNothing); + _expectMediumWidget(null); + } + + void _expectPlayerInitializationDone({int mainMediumIndex = 0}) { + final currentMedium = getMainMediumAt(mainMediumIndex); + final currentController = getMainControllerAt(mainMediumIndex); + expect(currentController.medium.resource, currentMedium.resource); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byKey(playerKey), findsOneWidget); + } + + void expectErrorWidget({int mainMediumIndex = 0}) { + final currentMedium = getMainMediumAt(mainMediumIndex); + _expectPlayerInitializationDone(mainMediumIndex: mainMediumIndex); + _expectMediumWidget(null); + expect(find.byType(MediaPlayerError), findsOneWidget); + expect(currentMedium.info.size, isNull); + } + + void expectPlayerWidget({int mainMediumIndex = 0, bool rotated}) { + final currentMedium = getMainMediumAt(mainMediumIndex); + _expectPlayerInitializationDone(mainMediumIndex: mainMediumIndex); + _expectMediumWidget(currentMedium); + expect(find.byType(MediaPlayerError), findsNothing); + expect( + findSubString(currentMedium.info.exception.toString()), findsNothing); + if (rotated != null) { + expect(find.byType(RotatedBox), rotated ? findsOneWidget : findsNothing); + } + } + + void expectPlayingStatus({ + int mainMediumIndex = 0, + @required bool finished, + int stoppedTimes, + bool paused = false, + }) { + // TODO: add check for isPlaying. + final currentMedium = getMainMediumAt(mainMediumIndex); + final currentController = getMainControllerAt(mainMediumIndex); + stoppedTimes = stoppedTimes ?? (finished ? 1 : 0); + expect(playerHasStoppedTimes, stoppedTimes, + reason: ' Medium: ${currentMedium.resource}\n Key: stoppedTimes'); + // If it is null, then it wasn't created yet, so the medium wasn't really + // played yet and didn't receive any reactions + expect(currentController?.finishedTimes ?? 0, finished ? 1 : 0, + reason: ' Medium: ${currentMedium.resource}\n Key: finishedTimes'); + expect(currentController?.isPaused ?? false, paused, + reason: ' Medium: ${currentMedium.resource}'); + } +} + +class SingleMediumInfo { + final Size size; + final Duration duration; + final Duration initDelay; + final Exception exception; + final Key widgetKey; + SingleMediumInfo( + String location, { + this.size, + this.exception, + Duration duration, + Duration initDelay, + }) : assert(exception != null && size == null || + exception == null && size != null), + initDelay = initDelay ?? const Duration(seconds: 1), + duration = duration ?? const UnlimitedDuration(), + widgetKey = GlobalKey(debugLabel: 'widgetKey(${location}'); +} + +abstract class FakeSingleMedium extends SingleMedium { + final SingleMediumInfo info; + FakeSingleMedium( + String location, { + Duration maxDuration, + Size size, + Exception exception, + Duration duration, + Duration initDelay, + }) : info = SingleMediumInfo(location, + size: size, + exception: exception, + duration: duration, + initDelay: initDelay), + super(Uri.parse(location), maxDuration: maxDuration); +} + +class FakeAudibleSingleMedium extends FakeSingleMedium implements Audible { + FakeAudibleSingleMedium( + String location, { + Duration maxDuration, + Size size, + Exception exception, + Duration duration, + Duration initDelay, + }) : super(location, + maxDuration: maxDuration, + size: size, + exception: exception, + duration: duration, + initDelay: initDelay); +} + +class FakeVisualizableSingleMedium extends FakeSingleMedium + implements Visualizable { + FakeVisualizableSingleMedium( + String location, { + Duration maxDuration, + Size size, + Exception exception, + Duration duration, + Duration initDelay, + }) : super(location, + maxDuration: maxDuration, + size: size, + exception: exception, + duration: duration, + initDelay: initDelay); +} + +class FakeAudibleVisualizableSingleMedium extends FakeSingleMedium + implements Audible, Visualizable { + FakeAudibleVisualizableSingleMedium( + String location, { + Duration maxDuration, + Size size, + Exception exception, + Duration duration, + Duration initDelay, + }) : super(location, + maxDuration: maxDuration, + size: size, + exception: exception, + duration: duration, + initDelay: initDelay); +} + +class FakeSingleMediumController extends Fake + implements SingleMediumController { + // Internal state + Timer _initTimer; + bool get isInitializing => _initTimer?.isActive ?? false; + Timer _playingTimer; + bool get isPlaying => _playingTimer?.isActive ?? false; + final _initCompleter = Completer(); + // State to do checks + bool get initError => medium.info.exception != null; + bool _isDisposed = false; + bool get isDisposed => _isDisposed; + int _finishedTimes = 0; + int get finishedTimes => _finishedTimes; + bool _isPaused = false; + bool get isPaused => _isPaused; + + void Function(BuildContext) playerOnMediaStopped; + + @override + FakeSingleMedium medium; + + @override + Key widgetKey; + + FakeSingleMediumController( + this.medium, + this.playerOnMediaStopped, + this.widgetKey, + ) : assert(medium != null); + + @override + Future initialize(BuildContext context) { + _initTimer = Timer(medium.info.initDelay, () { + if (initError) { + try { + throw medium.info.exception; + } catch (e, stack) { + _initCompleter.completeError(e, stack); + } + return; + } + _initCompleter.complete(medium.info.size); + }); + + return _initCompleter.future; + } + + @override + Future play(BuildContext context) { + // TODO: test play errors + // Trigger onMediumFinished after the duration of the media to simulate + // a natural stop if a duration is set + if (medium.info.duration is! UnlimitedDuration) { + _playingTimer = Timer(medium.info.duration, () { + onMediumFinished(context); + }); + } + return Future.value(); + } + + @override + Future dispose() async { + _initTimer?.cancel(); + _playingTimer?.cancel(); + _isDisposed = true; + } + + @override + Future pause(BuildContext context) async { + _isPaused = true; + } + + @override + void Function(BuildContext) get onMediumFinished => (BuildContext context) { + _finishedTimes++; + playerOnMediaStopped(context); + }; + + @override + Widget build(BuildContext context) => Container(key: widgetKey); +} diff --git a/test/unit/menu_build_extension_test.dart b/test/unit/menu_build_extension_test.dart new file mode 100644 index 0000000..2eb3fbd --- /dev/null +++ b/test/unit/menu_build_extension_test.dart @@ -0,0 +1,256 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' hide Orientation, Action; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:flutter_grid_button/flutter_grid_button.dart' + show GridButtonItem; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show GridMenu, Button, Action, Menu; +import 'package:lunofono_player/src/action_player.dart' + show ActionPlayer, ActionPlayerRegistry; +import 'package:lunofono_player/src/button_player.dart' + show ButtonPlayer, ButtonPlayerRegistry; +import 'package:lunofono_player/src/menu_player.dart'; + +void main() { + group('MenuPlayer', () { + FakeButton fakeButtonRed; + FakeButton fakeButtonBlue; + final oldActionRegistry = ActionPlayer.registry; + final oldButtonRegistry = ButtonPlayer.registry; + + setUp(() { + fakeButtonRed = FakeButton(Colors.red); + fakeButtonBlue = FakeButton(Colors.blue); + + ActionPlayer.registry = ActionPlayerRegistry(); + ActionPlayer.registry.register( + FakeAction, (action) => FakeActionPlayer(action as FakeAction)); + + ButtonPlayer.registry = ButtonPlayerRegistry(); + ButtonPlayer.registry + .register(FakeButton, (b) => FakeButtonPlayer(b as FakeButton)); + }); + + tearDown(() { + ActionPlayer.registry = oldActionRegistry; + ButtonPlayer.registry = oldButtonRegistry; + }); + + group('MenuPlayerRegistry', () { + final oldMenuRegistry = MenuPlayer.registry; + FakeContext fakeContext; + FakeMenu fakeMenu; + + setUp(() { + fakeContext = FakeContext(); + fakeMenu = FakeMenu(); + }); + + tearDown(() => MenuPlayer.registry = oldMenuRegistry); + + test('empty', () { + MenuPlayer.registry = MenuPlayerRegistry(); + expect(MenuPlayer.registry, isEmpty); + expect(() => MenuPlayer.wrap(fakeMenu), throwsAssertionError); + }); + + test('registration and calling from empty', () { + MenuPlayer.registry = MenuPlayerRegistry(); + MenuPlayer.registry + .register(FakeMenu, (m) => FakeMenuPlayer(m as FakeMenu)); + + final builtWidget = MenuPlayer.wrap(fakeMenu).build(fakeContext); + expect(fakeMenu.buildCalls.length, 1); + expect(fakeMenu.buildCalls.last.context, same(fakeContext)); + expect(fakeMenu.buildCalls.last.returnedWidget, same(builtWidget)); + }); + + group('builtin', () { + group('GridMenuPlayer', () { + Menu menu; + MenuPlayer menuPlayer; + + setUp(() { + // XXX: We need to build this in the setUp() method because + // fakeButtonXxx are also built in a setUp() method because they + // store state, and need to be reset on each test run. + menu = GridMenu( + rows: 1, + columns: 2, + buttons: [ + fakeButtonRed, + fakeButtonBlue, + ], + ); + menuPlayer = MenuPlayer.wrap(menu); + }); + + test('constructor asserts if menu is null', () { + expect(() => GridMenuPlayer(null), throwsAssertionError); + }); + + testWidgets( + 'build() builds the right widgets', + (WidgetTester tester) async { + expect(menuPlayer, isA()); + final gridMenuPlayer = menuPlayer as GridMenuPlayer; + final gridMenu = menu as GridMenu; + + // Matches the underlaying menu + expect(gridMenuPlayer.rows, gridMenu.rows); + expect(gridMenuPlayer.columns, gridMenu.columns); + expect( + gridMenuPlayer.buttons.first.button, gridMenu.buttonAt(0, 0)); + + // Build returns a GridMenuWidget + final menuWidget = menuPlayer.build(fakeContext); + expect(menuWidget, isA()); + expect((menuWidget as GridMenuWidget).menu, same(menuPlayer)); + + // Builds the right buttons (we have text, so we need + // a Directionality) + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: menuWidget, + )); + expect(fakeButtonRed.createCalls.length, 1); + expect( + (fakeButtonRed.createCalls.last.value as FakeButtonPlayer) + .button, + same(fakeButtonRed)); + expect(fakeButtonRed.actCalls, isEmpty); + expect(fakeButtonBlue.createCalls.length, 1); + expect( + (fakeButtonBlue.createCalls.last.value as FakeButtonPlayer) + .button, + same(fakeButtonBlue)); + expect(fakeButtonBlue.actCalls, isEmpty); + }, + ); + + testWidgets('GridMenuWidget tap calls button.action.act()', + (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => menuPlayer.build(context), + ), + ), + ); + + Finder findButtonByColor(Color c) { + return find.byWidgetPredicate( + (w) => w is FlatButton && w.color == c, + ); + } + + // Tap red, only red's button act() was called + expect(findButtonByColor(Colors.red), findsOneWidget); + await tester.tap(findButtonByColor(Colors.red)); + await tester.pump(); + expect(fakeButtonRed.actCalls.length, 1); + expect(fakeButtonRed.actCalls.last.button, fakeButtonRed); + expect(fakeButtonBlue.actCalls, isEmpty); + + // Tap blue, only blue's button act() was called + expect(findButtonByColor(Colors.blue), findsOneWidget); + await tester.tap(findButtonByColor(Colors.blue)); + await tester.pump(); + expect(fakeButtonRed.actCalls.length, 1); + expect(fakeButtonRed.actCalls.last.button, fakeButtonRed); + expect(fakeButtonBlue.actCalls.length, 1); + expect(fakeButtonBlue.actCalls.last.button, fakeButtonBlue); + + // Tap blue 3 times, only blue's button act() was called 3 times + for (var i = 1; i <= 3; i++) { + expect(findButtonByColor(Colors.blue), findsOneWidget); + await tester.tap(findButtonByColor(Colors.blue)); + await tester.pump(); + expect(fakeButtonRed.actCalls.length, 1); + expect(fakeButtonRed.actCalls.last.button, fakeButtonRed); + expect(fakeButtonBlue.actCalls.length, 1 + i); + expect(fakeButtonBlue.actCalls.last.button, fakeButtonBlue); + } + + // Tap red again, only red's button act() was called + expect(findButtonByColor(Colors.red), findsOneWidget); + await tester.tap(findButtonByColor(Colors.red)); + await tester.pump(); + expect(fakeButtonRed.actCalls.length, 2); + expect(fakeButtonRed.actCalls.last.button, fakeButtonRed); + expect(fakeButtonBlue.actCalls.length, 4); + expect(fakeButtonBlue.actCalls.last.button, fakeButtonBlue); + }); + }); + }); + }); + }); +} + +class FakeContext extends Fake implements BuildContext {} + +class BuildCall { + final BuildContext context; + final Widget returnedWidget; + BuildCall(this.context, this.returnedWidget); +} + +class FakeMenu extends Menu { + final buildCalls = []; +} + +class FakeMenuPlayer extends MenuPlayer { + @override + final FakeMenu menu; + FakeMenuPlayer(this.menu) : assert(menu != null); + static Key globalKey = GlobalKey(debugLabel: 'FakeMenuPlayerKey'); + @override + Widget build(BuildContext context) { + final widget = Container(child: Text('FakeMenu'), key: globalKey); + menu.buildCalls.add(BuildCall(context, widget)); + return widget; + } +} + +class FakeAction extends Action { + final actCalls = []; +} + +class FakeActionPlayer extends ActionPlayer { + @override + final FakeAction action; + const FakeActionPlayer(this.action) : assert(action != null); + @override + void act(BuildContext context, ButtonPlayer button) => + action.actCalls.add(button); +} + +class FakeButton extends Button { + final Color color; + FakeButton(this.color) : super(FakeAction()); + final createCalls = []; + List get actCalls => (action as FakeAction).actCalls; +} + +class FakeButtonPlayer extends ButtonPlayer { + @override + final FakeButton button; + @override + GridButtonItem create(BuildContext context) { + final item = GridButtonItem( + value: this, + title: '', + color: button.color, + ); + button.createCalls.add(item); + return item; + } + + FakeButtonPlayer(this.button) : super(button); +} diff --git a/test/unit/platform_services_test.dart b/test/unit/platform_services_test.dart new file mode 100644 index 0000000..9149968 --- /dev/null +++ b/test/unit/platform_services_test.dart @@ -0,0 +1,62 @@ +@Tags(['unit', 'platform']) + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:lunofono_bundle/lunofono_bundle.dart' show Orientation; +import 'package:lunofono_player/src/platform_services.dart'; + +// XXX: Note that these tests will just test that the basic API is as expected, +// and no errors are thrown when services are used, but it DOES NOT verify that +// the services are doing what they are supposed to do. We just rely on +// upstream being tested (finger crossed). +// +// They are also included as Widget tests as the platform services need the +// Flutter machinery to be up and running to be invoked. + +void main() { + group('PlatformServices', () { + // These are testWidgets() calls instead of plain dart test() calls because + // some bindings need to be initialized to use platform services. + void testInstance(PlatformServices instance) { + testWidgets('setFullScreen()', (WidgetTester tester) async { + expect( + () async => await instance.setFullScreen(on: true), + returnsNormally, + ); + expect( + () async => await instance.setFullScreen(on: false), + returnsNormally, + ); + }); + + testWidgets('setOrientation()', (WidgetTester tester) async { + for (final orientation in Orientation.values) { + expect( + () async => await instance.setOrientation(orientation), + returnsNormally, + reason: 'Orientation: $orientation', + ); + } + }); + + testWidgets('inhibitScreenOff()', (WidgetTester tester) async { + expect( + () async => await instance.inhibitScreenOff(on: true), + returnsNormally, + ); + expect( + () async => await instance.inhibitScreenOff(on: false), + returnsNormally, + ); + }); + } + + group('new const instance', () { + testInstance(const PlatformServices()); + }); + + group('new instance', () { + testInstance(PlatformServices()); + }); + }); +} diff --git a/test/unit/playable_player_test.dart b/test/unit/playable_player_test.dart new file mode 100644 index 0000000..7f61e1c --- /dev/null +++ b/test/unit/playable_player_test.dart @@ -0,0 +1,131 @@ +@Tags(['unit', 'player']) + +import 'package:flutter/material.dart' hide Image; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart' show Fake; + +import 'package:lunofono_bundle/lunofono_bundle.dart' + show Audio, Image, Video, Playable, Color; +import 'package:lunofono_player/src/media_player.dart' show MediaPlayer; +import 'package:lunofono_player/src/playable_player.dart'; + +void main() { + group('PlayablePlayer', () { + final oldPlayableRegistry = PlayablePlayer.registry; + FakeContext fakeContext; + FakePlayable fakePlayable; + Color color; + + setUp(() { + fakePlayable = FakePlayable(); + fakeContext = FakeContext(); + color = Color(0x3845bd34); + }); + + tearDown(() => PlayablePlayer.registry = oldPlayableRegistry); + + test('empty', () { + PlayablePlayer.registry = PlayablePlayerRegistry(); + expect(PlayablePlayer.registry, isEmpty); + expect(() => PlayablePlayer.wrap(fakePlayable), throwsAssertionError); + }); + + test('registration and calling from empty', () { + PlayablePlayer.registry = PlayablePlayerRegistry(); + PlayablePlayer.registry.register(FakePlayable, + (playable) => FakePlayablePlayer(playable as FakePlayable)); + PlayablePlayer.wrap(fakePlayable).play(fakeContext, color); + expect(fakePlayable.calledPlayable, same(fakePlayable)); + expect(fakePlayable.calledContext, same(fakeContext)); + expect(fakePlayable.calledColor, same(color)); + }); + + group('builtin', () { + final homeKey = GlobalKey(debugLabel: 'homeKey'); + + void testPlayable(WidgetTester tester, Widget homeWidget) async { + // We need a MaterialApp to use the Navigator + await tester.pumpWidget(MaterialApp(home: homeWidget)); + final homeFinder = find.byKey(homeKey); + expect(homeFinder, findsOneWidget); + expect(find.byType(MediaPlayer), findsNothing); + + // We tap on the HomeWidget to call playable.play() + await tester.tap(homeFinder); + // One pump seems to be needed to process the Navigator.push(), this + // idiom is also being used in Flutter Navigator tests: + // https://github.com/flutter/flutter/blob/1.20.3/packages/flutter/test/widgets/navigator_test.dart + await tester.pump(); + // The second pump we wait a bit because Navigator is animated + await tester.pump(Duration(seconds: 1)); + expect(find.byKey(homeKey), findsNothing); + final playerFinder = find.byType(MediaPlayer); + expect(playerFinder, findsOneWidget); + final mediaPlayer = tester.widget(playerFinder) as MediaPlayer; + final context = tester.element(playerFinder); + + // The HomeWidget should be back + mediaPlayer.onMediaStopped(context); // Should call Navigator.pop() + await tester.pump(); // Same with .push() about the double pump() + await tester.pump(Duration(seconds: 1)); + expect(find.byKey(homeKey), findsOneWidget); + expect(find.byType(MediaPlayer), findsNothing); + } + + testWidgets('Audio', (WidgetTester tester) async { + final audio = PlayablePlayer.wrap(Audio(Uri.parse('audio'))); + await testPlayable(tester, HomeWidgetPlayable(audio, key: homeKey)); + }); + + testWidgets('Image', (WidgetTester tester) async { + final image = PlayablePlayer.wrap(Image(Uri.parse('image'))); + await testPlayable(tester, HomeWidgetPlayable(image, key: homeKey)); + }); + + testWidgets('Video', (WidgetTester tester) async { + final video = PlayablePlayer.wrap(Video(Uri.parse('video'))); + await testPlayable(tester, HomeWidgetPlayable(video, key: homeKey)); + }); + }); + }); +} + +class FakeContext extends Fake implements BuildContext {} + +class FakePlayable extends Playable { + Playable calledPlayable; + BuildContext calledContext; + Color calledColor; +} + +class FakePlayablePlayer extends PlayablePlayer { + @override + final FakePlayable playable; + @override + void play(BuildContext context, [Color backgroundColor]) { + playable.calledPlayable = playable; + playable.calledContext = context; + playable.calledColor = backgroundColor; + } + + FakePlayablePlayer(this.playable) : assert(playable != null); +} + +class HomeWidgetPlayable extends StatelessWidget { + final Color color = Colors.red; + final PlayablePlayer playable; + const HomeWidgetPlayable(this.playable, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + playable.play(context, color); + }, + child: Container( + child: const Text('home'), + ), + ); + } +} diff --git a/test/util/finders.dart b/test/util/finders.dart new file mode 100644 index 0000000..836f235 --- /dev/null +++ b/test/util/finders.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart' show Text; + +import 'package:flutter_test/flutter_test.dart' show find, Finder; + +/// Finds a [Text] widget whose content contains the [substring]. +Finder findSubString(String substring) { + return find.byWidgetPredicate((widget) { + if (widget is Text) { + if (widget.data != null) { + return widget.data.contains(substring); + } + return widget.textSpan.toPlainText().contains(substring); + } + return false; + }); +} diff --git a/test/util/foundation.dart b/test/util/foundation.dart new file mode 100644 index 0000000..fac512a --- /dev/null +++ b/test/util/foundation.dart @@ -0,0 +1,7 @@ +import 'package:flutter/foundation.dart' show DiagnosticLevel; + +abstract class FakeDiagnosticableMixin { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.debug}) => + super.toString(); +} diff --git a/test/util/test_asset_bundle.dart b/test/util/test_asset_bundle.dart new file mode 100644 index 0000000..2a98640 --- /dev/null +++ b/test/util/test_asset_bundle.dart @@ -0,0 +1,38 @@ +import 'dart:io' show File, Directory; +import 'dart:typed_data' show ByteData, Uint8List; +import 'package:flutter/services.dart' show CachingAssetBundle; + +/// An AssetBundle that gets assets synchronously for testing. +/// +/// This class assumes an assets directory exists and has a valid +/// `AssetManifest.json` file and all other resources present in a final app +/// bundle. An easy way to get this is to just copy the `build/flutter_assets` +/// directory (or `build/unit_test_assets` directory) as a starting point and +/// adapt it to your testing needs. +class TestAssetBundle extends CachingAssetBundle { + /// The directory where the assets should be looked for. + final String assetsDirectory; + + /// Creates a new test bundle. + /// + /// If [assetsDirectory] is not provided or null, `test/asset_bundle` will be + /// used by default, so assets will be looked for in `test/asset_bundle`. This + /// directory should always be relative to the top-level directory of the + /// project. `flutter test` will change the working directory to `test` but + /// `flutter test file.dart` will not. This class accounts for this difference + /// and makes the assets always load from the top-level directory of the + /// project so you don't have to worry about it. + TestAssetBundle([String assetsDirectory]) + : assetsDirectory = assetsDirectory ?? 'test/asset_bundle'; + + @override + Future load(String key) { + final cwd = Directory.current; + // Makes up for `flutter test` vs `flutter run ` differences, see + // the constructor documentation for details. + final dir = cwd.path.split('/').last == 'test' ? '..' : '.'; + final file = File('$dir/$assetsDirectory/$key'); + final fileContents = Uint8List.fromList(file.readAsBytesSync()); + return Future.value(ByteData.view(fileContents.buffer)); + } +}