diff --git a/README.md b/README.md index eff2dc5..f13618f 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ Shared Dart packages across Halkyon apps, a pub workspace: - [`halkyon_ui`](packages/halkyon_ui): Halkyon's look, the theme, fonts, layout and shared widgets (Flutter). +- [`halkyon_app`](packages/halkyon_app): what every app is built around, on an + instance's `halkyon_base`: the instances a device keeps accounts on, signing + in, each person's data opened and closed, and syncing it (Flutter). - [`halkyon_base`](packages/halkyon_base_server/README.md): what every Halkyon server has, as a Serverpod module (`halkyon_base_server` and its generated client `halkyon_base_client`): signing in, the account and its deletion, the pruning, and the instance's basics. -- [`halkyon_core`](packages/halkyon_core): low-level primitives (pure Dart). +- [`halkyon_core`](packages/halkyon_core): low-level primitives (pure Dart), + `SerialQueue` and `CoalescingRunner` among them. - [`halkyon_interop`](packages/halkyon_interop): cross-app contracts (pure Dart). Dart 3.13 and Flutter 3.47, with LeanCode's lints (`leancode_lint`), configured @@ -16,8 +20,8 @@ once in `analysis_options.yaml` here, which every package includes but the generated `halkyon_base_client`: its analyzer plugin only reaches packages of a workspace from the root. Run `flutter pub get` and `dart analyze --fatal-infos` here, and each package's -tests in its folder (`dart test`, or `flutter test` for `halkyon_ui` and its -example). `halkyon_base_server`'s tests start Serverpod's embedded PostgreSQL by +tests in its folder (`dart test`, or `flutter test` for `halkyon_ui`, its +example and `halkyon_app`). `halkyon_base_server`'s tests start Serverpod's embedded PostgreSQL by themselves and need a git-ignored `config/passwords.yaml` (see its README). Licences: the server package `halkyon_base_server` is under the Server Side diff --git a/packages/halkyon_app/.gitignore b/packages/halkyon_app/.gitignore new file mode 100644 index 0000000..dd5eb98 --- /dev/null +++ b/packages/halkyon_app/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/halkyon_app/CHANGELOG.md b/packages/halkyon_app/CHANGELOG.md new file mode 100644 index 0000000..39edc70 --- /dev/null +++ b/packages/halkyon_app/CHANGELOG.md @@ -0,0 +1,8 @@ +## 0.1.0 + +* Halkyon Learn's instances, sign-in and sync plumbing, for every Halkyon app, + on an instance's `halkyon_base`: `InstanceChoice` and the first start, + `InstanceGate`, `SessionGate` and `PersonScope` over an app's + `AppDependencies`, `ServerpodAuthGateway` with its blocs and the email + sheet, `SyncService` with its status and widgets, texts of their own in + English and German that name the app (`AppFrame`), and a `testing` library. diff --git a/packages/halkyon_app/LICENSE b/packages/halkyon_app/LICENSE new file mode 100644 index 0000000..8e085dc --- /dev/null +++ b/packages/halkyon_app/LICENSE @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 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 Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are 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. + +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. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +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 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 work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero 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 Affero 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 Affero 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 Affero 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. + + halkyon_shared + Copyright (C) 2026 project_halkyon + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +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 AGPL, see . diff --git a/packages/halkyon_app/README.md b/packages/halkyon_app/README.md new file mode 100644 index 0000000..4ec6cbe --- /dev/null +++ b/packages/halkyon_app/README.md @@ -0,0 +1,109 @@ +# halkyon_app + +What every Halkyon app (Learn, Todo, Train) is built around, on an instance's +`halkyon_base` module: the instances a device keeps an account on, signing in, +each person's data opened and closed, and syncing it. Taken from Halkyon Learn, +which it was written for, so it behaves as Learn's own did; the texts are in +British English, American English and German, and where they name the app, +they name yours. + +Written as the apps are: Dart 3.13's primary constructors, Material from +`package:material_ui`, Lucide icons, blocs (`flutter_bloc`), LeanCode's lints. + +## What it holds + +- **Instances** (`lib/src/instance/`): `Instance` (an address as people type + it), `InstanceChoice` (those the device keeps an account on, one in use; + `FileInstanceStore`), `InstanceStorage` (each instance's people's folders and + session key), `InstanceChecker` (does an address answer, where does it keep + people's data; `ServerpodInstanceChecker` asks its `instance.basics()`), + `InstanceScope`. The screens: `FirstStartScreen` (the build's instance and + another, as tiles, `InstanceTile`), `InstanceStep` (an address, checked), + `showAccountSwitcher` and `AddAccountScreen`, and where an instance keeps + people's data, as a flag and words (`InstanceLocationBuilder`, + `InstanceFlag`, `instanceLocationWords`, countries named by the app's + `CountryNames`). +- **Gates** (`lib/src/app/`): `InstanceGate` opens the app's dependencies on + the instance in use (`AppDependencies`, handed down by `AppScope`), anew on + a switch, the previous ones closed first; `SessionGate` keeps a person + signed in, signing in anonymously at a first start and asking how to start + once signed out (`StartScreen`); `PersonScope` opens each person's own + dependencies and closes them when the person goes. +- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`; + `ServerpodAuthGateway` signs in anonymously, with GitHub (the system + browser and a loopback address on Linux and Windows, `LoopbackWebAuth`) and + with a code by mail, through the instance's `halkyon_base`; `SessionBloc`, + `GitHubSignInBloc`, `EmailSignInBloc` (with `showEmailSignInSheet`) and + `SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`. +- **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after + local changes, on resume and every few minutes, one sync at a time, counting + the changes not on the server yet, `PendingChangesStore`), `AppCompatibility` + and `compatibleSync` (the instance's minimum app build, the engine's table + check), `SyncStatusBloc`, `syncAndWait`, and the widgets: `SyncStatusCard`, + `AppMismatchBanner`, `SyncTroubleBadge`. +- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance + about itself, and to sign in as another person than the app's client holds. +- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary + (`FakeAuthGateway`, `FakeInstanceChecker`, the stores in memory), a frame + (`testFrame`), `testApp`, and `TestAppDependencies`. + +## Using it in an app + +1. **The server** includes `halkyon_base` with the nickname `base` (its + README), so the app's generated client has `client.modules.base`. +2. **Depend on it** by git, pinned to a signed commit of halkyon_shared's + main, as `halkyon_ui` (whose README shows the `pubspec_overrides.yaml` for + working on both at once): + + ```yaml + dependencies: + halkyon_app: + git: + url: https://code.halkyon.app/project-halkyon/halkyon_shared.git + path: packages/halkyon_app + ref: + ``` + +3. **Texts**: add `HalkyonAppLocalizations.delegate` to every `MaterialApp`'s + delegates, beside `HalkyonUiLocalizations.delegate` and material_ui's + `GlobalMaterialLocalizations.delegates`. +4. **The frame** (`AppFrame`): the app's name and wordmark word, its icon, an + example address, its theme, delegates, languages and launch screen, and the + two sentences only it can say: what the first start's instance keeps of + the person's, and what a switch to another account leaves behind. +5. **The dependencies**: the app's composition root implements + `AppDependencies

` (`serverName`, `auth`, `signedOut`, `openPerson`, + `dispose`), its person's dependencies `Disposable`. Its `auth` is an + `AuthRepository` on `ServerpodAuthGateway(client, base: + client.modules.base, callbackScheme: …, appName: …)`; the scheme is the + app's own (`app.halkyon.learn`), registered with each platform and in the + instance's GitHub OAuth app, whose loopback address on Linux and Windows is + `http://127.0.0.1/github-callback` (port 43823 unless given). +6. **The tree**: + + ```dart + InstanceGate( + frame: frame, + choice: choice, + checker: ServerpodInstanceChecker(app: 'todo'), + countryNames: …, + readLook: …, // the device's theme mode and language + open: Dependencies.create, + app: SessionGate( + look: (context) => …, + app: (person) => PersonApp(person: person), // with PersonScope inside + ), + ) + ``` + + A person's widgets find their dependencies with + `PersonScope.of(context)` and the app's with + `AppScope.of(context)`. +7. **Licences**: call `registerFlagIconsLicense()` once at startup, beside + halkyon_ui's `registerFontLicenses()`. + +## Licence + +GNU Affero General Public License 3.0 or later (`LICENSE`). The flags are +lipis' flag-icons, drawn by the `flag` package, under the MIT licence +(`assets/flags/LICENSE-flag-icons.txt`). diff --git a/packages/halkyon_app/analysis_options.yaml b/packages/halkyon_app/analysis_options.yaml new file mode 100644 index 0000000..6b1df4b --- /dev/null +++ b/packages/halkyon_app/analysis_options.yaml @@ -0,0 +1,11 @@ +# The workspace's lint rules (see ../../analysis_options.yaml). +include: ../../analysis_options.yaml + +analyzer: + exclude: + - build/** + # Generated by gen-l10n (see l10n.yaml). + - lib/l10n/halkyon_app_localizations*.dart + +formatter: + trailing_commas: preserve diff --git a/packages/halkyon_app/assets/flags/LICENSE-flag-icons.txt b/packages/halkyon_app/assets/flags/LICENSE-flag-icons.txt new file mode 100644 index 0000000..ee959dc --- /dev/null +++ b/packages/halkyon_app/assets/flags/LICENSE-flag-icons.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Panayiotis Lipiridis + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/halkyon_app/l10n.yaml b/packages/halkyon_app/l10n.yaml new file mode 100644 index 0000000..a04cc3f --- /dev/null +++ b/packages/halkyon_app/l10n.yaml @@ -0,0 +1,14 @@ +# Flutter's gen-l10n: the strings in lib/l10n/*.arb become HalkyonAppLocalizations, +# generated into lib/l10n/halkyon_app_localizations*.dart (committed, never edited or +# formatted by hand, as apps take this package by git). halkyon_app_en.arb, the +# template, is British English, also the fallback; halkyon_app_en_US.arb overrides +# what American English spells differently; halkyon_app_en_GB.arb is empty; German +# says "du". Where a text names the app, it takes the app's name as `{app}`. +arb-dir: lib/l10n +template-arb-file: halkyon_app_en.arb +output-dir: lib/l10n +output-localization-file: halkyon_app_localizations.dart +output-class: HalkyonAppLocalizations +# `HalkyonAppLocalizations.of(context).foo` without a `!`: apps install the +# delegate. +nullable-getter: false diff --git a/packages/halkyon_app/lib/halkyon_app.dart b/packages/halkyon_app/lib/halkyon_app.dart new file mode 100644 index 0000000..f72051e --- /dev/null +++ b/packages/halkyon_app/lib/halkyon_app.dart @@ -0,0 +1,70 @@ +/// What every Halkyon app is built around, on an instance's halkyon_base: +/// +/// - **Instances**: the ones a device keeps an account on, one in use at a +/// time (`InstanceChoice`), chosen at first start (`FirstStartScreen`) or +/// added later (`showAccountSwitcher`), each checked before the app moves +/// there (`InstanceChecker`) and saying where it keeps people's data. +/// - **Gates**: `InstanceGate` opens the app's dependencies on the instance +/// in use (`AppDependencies`, `AppScope`); `SessionGate` keeps a person +/// signed in and asks how to start once signed out (`StartScreen`); +/// `PersonScope` opens each person's own dependencies. +/// - **Signing in**: anonymously, with GitHub or with a code by mail +/// (`AuthRepository` on `ServerpodAuthGateway`), and the blocs and sheet +/// that switch a device to another account. +/// - **Sync**: `SyncService` on triggers, the changes still waiting, and +/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`, +/// `SyncTroubleBadge`). +/// +/// An app gives its name, look and own words as an `AppFrame`, and installs +/// `HalkyonAppLocalizations.delegate` beside its own. +library; + +export 'l10n/halkyon_app_localizations.dart'; +export 'src/app/app_frame.dart' show AppFrame, AppFrameScope, LookSettings; +export 'src/app/app_scope.dart'; +export 'src/app/instance_gate.dart'; +export 'src/app/person_scope.dart'; +export 'src/auth/bloc/email_sign_in_bloc.dart'; +export 'src/auth/bloc/github_sign_in_bloc.dart'; +export 'src/auth/bloc/session_bloc.dart'; +export 'src/auth/bloc/sign_in_ways_cubit.dart'; +export 'src/auth/data/auth_gateway.dart'; +export 'src/auth/data/auth_repository.dart'; +export 'src/auth/data/loopback_web_auth.dart'; +export 'src/auth/data/serverpod_auth_gateway.dart'; +export 'src/auth/data/sign_in_addresses.dart'; +export 'src/auth/data/signed_out_mark.dart'; +export 'src/auth/model/auth_status.dart'; +export 'src/auth/model/email_sign_in.dart'; +export 'src/auth/model/github_link_outcome.dart'; +export 'src/auth/model/github_sign_in.dart'; +export 'src/auth/model/sign_in_ways.dart'; +export 'src/auth/widget/email_sign_in_sheet.dart'; +export 'src/auth/widget/session_gate.dart'; +export 'src/auth/widget/start_screen.dart'; +export 'src/base/base_client.dart'; +export 'src/instance/country_names.dart'; +export 'src/instance/flag_icons_license.dart'; +export 'src/instance/instance.dart'; +export 'src/instance/instance_checker.dart'; +export 'src/instance/instance_choice.dart'; +export 'src/instance/instance_locations.dart'; +export 'src/instance/instance_scope.dart'; +export 'src/instance/instance_storage.dart'; +export 'src/instance/instance_store.dart'; +export 'src/instance/widget/account_switcher_sheet.dart'; +export 'src/instance/widget/first_start_screen.dart'; +export 'src/instance/widget/instance_location.dart'; +export 'src/instance/widget/instance_location_words.dart'; +export 'src/instance/widget/instance_step.dart'; +export 'src/instance/widget/instance_tile.dart'; +export 'src/person/person_folders.dart'; +export 'src/sync/app_compatibility.dart'; +export 'src/sync/bloc/sync_status_bloc.dart'; +export 'src/sync/pending_changes_store.dart'; +export 'src/sync/sync_service.dart'; +export 'src/sync/sync_status.dart'; +export 'src/sync/widget/app_mismatch_banner.dart'; +export 'src/sync/widget/sync_and_wait.dart'; +export 'src/sync/widget/sync_status_card.dart'; +export 'src/sync/widget/sync_trouble_badge.dart'; diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_de.arb b/packages/halkyon_app/lib/l10n/halkyon_app_de.arb new file mode 100644 index 0000000..96dafe0 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_de.arb @@ -0,0 +1,78 @@ +{ + "@@locale": "de", + "tryAgain": "Erneut versuchen", + "instanceWelcome": "Willkommen bei {app}", + "instanceBuiltInTagline": "In der App voreingestellt", + "instanceAnother": "Andere Instanz", + "instanceAnotherTagline": "Gib ihre Adresse ein", + "instanceAddress": "Adresse der Instanz", + "instanceAddressInvalid": "Das ist keine Adresse.", + "instanceAddressRequired": "Gib die Adresse einer Instanz ein.", + "instanceContinue": "Weiter", + "instanceUnreachable": "Unter {name} antwortet keine Instanz von {app}.", + "instanceUseBuiltIn": "{name} verwenden", + "instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.", + "instanceDataAndBackupsIn": "Daten und Sicherungen: {place}", + "instanceDataIn": "Daten: {place}", + "instanceBackupsIn": "Sicherungen: {place}", + "instanceNoBackups": "Keine Sicherungen", + "instanceEuropeanUnion": "Europäische Union", + "accountsTitle": "Konten", + "accountAdd": "Konto hinzufügen", + "accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.", + "accountSignedIn": "Angemeldet", + "accountAnonymous": "Anonym", + "accountRemoveTooltip": "Konto entfernen", + "accountRemoveTitle": "Konto entfernen?", + "accountRemoveMessage": "Entfernt das Konto auf {name} von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf {name}: Melde dich dort wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.", + "accountRemoveConfirm": "Entfernen", + "sessionUnreachable": "Der Server war nicht erreichbar.", + "personDataFailed": "Die Daten dieses Geräts konnten nicht geöffnet werden.", + "startSignedOut": "Abgemeldet", + "startOn": "Starte auf {instance}:", + "startWithGitHub": "Mit GitHub anmelden", + "startWithEmail": "Mit E-Mail fortfahren", + "startWithoutAccount": "Ohne Konto starten", + "startAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.", + "startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.", + "startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Starte ohne Konto und füge GitHub dann im Profil hinzu.", + "startOtherInstance": "Andere Instanz verwenden", + "emailSignInTitle": "Mit E-Mail fortfahren", + "emailSignInIntro": "Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.", + "emailSignInAddress": "E-Mail-Adresse", + "emailSignInSend": "Code senden", + "emailSignInCodeSent": "Wir haben einen Code an {address} geschickt. Er gilt 15 Minuten.", + "emailSignInResent": "Ein neuer Code ist unterwegs.", + "emailSignInCode": "Code", + "emailSignInContinue": "Weiter", + "emailSignInResend": "Neuen Code senden", + "emailSignInOtherAddress": "Andere Adresse verwenden", + "emailSignInSwitchTitle": "Zum Konto von {address} wechseln?", + "emailSignInSwitchConfirm": "Wechseln", + "emailSignInSwitchStay": "Anonym bleiben", + "emailSignInTaken": "Mit {address} meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.", + "emailSignInUnavailable": "Diese Instanz bietet keine Anmeldung mit E-Mail an.", + "emailSignInNotAnAddress": "Das ist keine E-Mail-Adresse.", + "emailSignInTooManyCodes": "Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.", + "emailSignInWrongCode": "Dieser Code stimmt nicht.", + "emailSignInTooManyTries": "Zu viele falsche Codes. Fordere einen neuen an.", + "emailSignInCodeExpired": "Dieser Code gilt nicht mehr. Fordere einen neuen an.", + "emailSignInOtherAddressLinked": "Du meldest dich schon mit einer anderen Adresse an.", + "emailSignInMailFailed": "Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.", + "emailSignInFailed": "Die Instanz war nicht erreichbar. Versuche es noch einmal.", + "syncTitle": "Synchronisierung", + "syncNow": "Jetzt synchronisieren", + "syncStateSyncing": "Wird synchronisiert…", + "syncStateSynced": "Alles synchronisiert", + "syncStateFailing": "Synchronisierung schlägt fehl", + "syncStateNotYet": "Noch nicht synchronisiert", + "syncStateMismatch": "Diese App kann nicht mit {server} synchronisieren", + "syncMismatchDetail": "Aktualisiere die App. Ist sie schon aktuell, muss der Server aktualisiert werden. Was du bis dahin tust, bleibt auf diesem Gerät und wird danach synchronisiert.", + "syncLastCompleted": "Zuletzt abgeschlossen um {time}", + "syncLastFailed": "Letzter Versuch fehlgeschlagen um {time}", + "syncNoRunYet": "Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen", + "syncPending": "{count, plural, =1{1 Änderung wartet auf die Synchronisierung} other{{count} Änderungen warten auf die Synchronisierung}}", + "syncTroubleLabel": "Synchronisierung benötigt Aufmerksamkeit", + "syncMismatchBanner": "Diese App kann nicht mehr mit {server} synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.", + "syncMismatchMore": "Mehr" +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_en.arb b/packages/halkyon_app/lib/l10n/halkyon_app_en.arb new file mode 100644 index 0000000..367ed77 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_en.arb @@ -0,0 +1,201 @@ +{ + "@@locale": "en", + "tryAgain": "Try again", + "instanceWelcome": "Welcome to {app}", + "@instanceWelcome": { + "description": "Screen readers hear it for the wordmark on the first start.", + "placeholders": { + "app": { + "type": "String" + } + } + }, + "instanceBuiltInTagline": "Comes with the app", + "instanceAnother": "Another instance", + "instanceAnotherTagline": "Enter its address", + "instanceAddress": "Instance address", + "instanceAddressInvalid": "This isn't an address.", + "instanceAddressRequired": "Enter the address of an instance.", + "instanceContinue": "Continue", + "instanceUnreachable": "No instance of {app} answers at {name}.", + "@instanceUnreachable": { + "placeholders": { + "app": { + "type": "String" + }, + "name": { + "type": "String" + } + } + }, + "instanceUseBuiltIn": "Use {name}", + "@instanceUseBuiltIn": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "instanceOpenFailed": "Couldn't open the app on {name}.", + "@instanceOpenFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "instanceDataAndBackupsIn": "Data and backups: {place}", + "@instanceDataAndBackupsIn": { + "placeholders": { + "place": { + "type": "String" + } + } + }, + "instanceDataIn": "Data: {place}", + "@instanceDataIn": { + "placeholders": { + "place": { + "type": "String" + } + } + }, + "instanceBackupsIn": "Backups: {place}", + "@instanceBackupsIn": { + "placeholders": { + "place": { + "type": "String" + } + } + }, + "instanceNoBackups": "No backups", + "instanceEuropeanUnion": "European Union", + "accountsTitle": "Accounts", + "accountAdd": "Add account", + "accountAddSubtitle": "Choose the instance for the account to add.", + "accountSignedIn": "Signed in", + "accountAnonymous": "Anonymous", + "accountRemoveTooltip": "Remove account", + "accountRemoveTitle": "Remove account?", + "accountRemoveMessage": "Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it. An anonymous account can't be signed into again.", + "@accountRemoveMessage": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "accountRemoveConfirm": "Remove", + "sessionUnreachable": "Couldn't reach the server.", + "personDataFailed": "Couldn't open this device's data.", + "startSignedOut": "Signed out", + "startOn": "Start on {instance}:", + "@startOn": { + "placeholders": { + "instance": { + "type": "String" + } + } + }, + "startWithGitHub": "Sign in with GitHub", + "startWithEmail": "Continue with email", + "startWithoutAccount": "Start without an account", + "startAnonymous": "Progress is saved on this device. Sign in to keep it.", + "startGitHubFailed": "Couldn't sign in with GitHub.", + "startNoGitHubAccount": "No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.", + "startOtherInstance": "Use another instance", + "emailSignInTitle": "Continue with email", + "emailSignInIntro": "We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.", + "emailSignInAddress": "Email address", + "emailSignInSend": "Send code", + "emailSignInCodeSent": "We mailed a code to {address}. It's valid for 15 minutes.", + "@emailSignInCodeSent": { + "placeholders": { + "address": { + "type": "String" + } + } + }, + "emailSignInResent": "A new code is on its way.", + "emailSignInCode": "Code", + "emailSignInContinue": "Continue", + "emailSignInResend": "Send a new code", + "emailSignInOtherAddress": "Use another address", + "emailSignInSwitchTitle": "Switch to the account of {address}?", + "@emailSignInSwitchTitle": { + "placeholders": { + "address": { + "type": "String" + } + } + }, + "emailSignInSwitchConfirm": "Switch", + "emailSignInSwitchStay": "Stay anonymous", + "emailSignInTaken": "{address} already signs in to another account. To switch to it, sign out first.", + "@emailSignInTaken": { + "placeholders": { + "address": { + "type": "String" + } + } + }, + "emailSignInUnavailable": "This instance doesn't offer signing in with email.", + "emailSignInNotAnAddress": "This isn't an email address.", + "emailSignInTooManyCodes": "Too many codes in the last hour. Try again later.", + "emailSignInWrongCode": "That code isn't right.", + "emailSignInTooManyTries": "Too many wrong codes. Ask for a new one.", + "emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.", + "emailSignInOtherAddressLinked": "You already sign in with another address.", + "emailSignInMailFailed": "The instance couldn't send the mail. Try again later.", + "emailSignInFailed": "Couldn't reach the instance. Try again.", + "syncTitle": "Sync", + "syncNow": "Sync now", + "syncStateSyncing": "Syncing…", + "syncStateSynced": "Everything synced", + "syncStateFailing": "Sync is failing", + "syncStateNotYet": "Not synced yet", + "syncStateMismatch": "This app can't sync with {server}", + "@syncStateMismatch": { + "placeholders": { + "server": { + "type": "String" + } + } + }, + "syncMismatchDetail": "Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.", + "syncLastCompleted": "Last completed at {time}", + "@syncLastCompleted": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "syncLastFailed": "Last attempt failed at {time}", + "@syncLastFailed": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "syncNoRunYet": "No sync has completed since the app started", + "syncPending": "{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}", + "@syncPending": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncTroubleLabel": "Sync needs attention", + "syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.", + "@syncMismatchBanner": { + "placeholders": { + "server": { + "type": "String" + } + } + }, + "syncMismatchMore": "More" +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_en_GB.arb b/packages/halkyon_app/lib/l10n/halkyon_app_en_GB.arb new file mode 100644 index 0000000..2a45d90 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_en_GB.arb @@ -0,0 +1,3 @@ +{ + "@@locale": "en_GB" +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_en_US.arb b/packages/halkyon_app/lib/l10n/halkyon_app_en_US.arb new file mode 100644 index 0000000..85a5cb7 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_en_US.arb @@ -0,0 +1,3 @@ +{ + "@@locale": "en_US" +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart b/packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart new file mode 100644 index 0000000..535762c --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart @@ -0,0 +1,605 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'halkyon_app_localizations_de.dart'; +import 'halkyon_app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of HalkyonAppLocalizations +/// returned by `HalkyonAppLocalizations.of(context)`. +/// +/// Applications need to include `HalkyonAppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/halkyon_app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: HalkyonAppLocalizations.localizationsDelegates, +/// supportedLocales: HalkyonAppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the HalkyonAppLocalizations.supportedLocales +/// property. +abstract class HalkyonAppLocalizations { + HalkyonAppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static HalkyonAppLocalizations of(BuildContext context) { + return Localizations.of( + context, + HalkyonAppLocalizations, + )!; + } + + static const LocalizationsDelegate delegate = + _HalkyonAppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('de'), + Locale('en'), + Locale('en', 'GB'), + Locale('en', 'US'), + ]; + + /// No description provided for @tryAgain. + /// + /// In en, this message translates to: + /// **'Try again'** + String get tryAgain; + + /// Screen readers hear it for the wordmark on the first start. + /// + /// In en, this message translates to: + /// **'Welcome to {app}'** + String instanceWelcome(String app); + + /// No description provided for @instanceBuiltInTagline. + /// + /// In en, this message translates to: + /// **'Comes with the app'** + String get instanceBuiltInTagline; + + /// No description provided for @instanceAnother. + /// + /// In en, this message translates to: + /// **'Another instance'** + String get instanceAnother; + + /// No description provided for @instanceAnotherTagline. + /// + /// In en, this message translates to: + /// **'Enter its address'** + String get instanceAnotherTagline; + + /// No description provided for @instanceAddress. + /// + /// In en, this message translates to: + /// **'Instance address'** + String get instanceAddress; + + /// No description provided for @instanceAddressInvalid. + /// + /// In en, this message translates to: + /// **'This isn\'t an address.'** + String get instanceAddressInvalid; + + /// No description provided for @instanceAddressRequired. + /// + /// In en, this message translates to: + /// **'Enter the address of an instance.'** + String get instanceAddressRequired; + + /// No description provided for @instanceContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get instanceContinue; + + /// No description provided for @instanceUnreachable. + /// + /// In en, this message translates to: + /// **'No instance of {app} answers at {name}.'** + String instanceUnreachable(String app, String name); + + /// No description provided for @instanceUseBuiltIn. + /// + /// In en, this message translates to: + /// **'Use {name}'** + String instanceUseBuiltIn(String name); + + /// No description provided for @instanceOpenFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the app on {name}.'** + String instanceOpenFailed(String name); + + /// No description provided for @instanceDataAndBackupsIn. + /// + /// In en, this message translates to: + /// **'Data and backups: {place}'** + String instanceDataAndBackupsIn(String place); + + /// No description provided for @instanceDataIn. + /// + /// In en, this message translates to: + /// **'Data: {place}'** + String instanceDataIn(String place); + + /// No description provided for @instanceBackupsIn. + /// + /// In en, this message translates to: + /// **'Backups: {place}'** + String instanceBackupsIn(String place); + + /// No description provided for @instanceNoBackups. + /// + /// In en, this message translates to: + /// **'No backups'** + String get instanceNoBackups; + + /// No description provided for @instanceEuropeanUnion. + /// + /// In en, this message translates to: + /// **'European Union'** + String get instanceEuropeanUnion; + + /// No description provided for @accountsTitle. + /// + /// In en, this message translates to: + /// **'Accounts'** + String get accountsTitle; + + /// No description provided for @accountAdd. + /// + /// In en, this message translates to: + /// **'Add account'** + String get accountAdd; + + /// No description provided for @accountAddSubtitle. + /// + /// In en, this message translates to: + /// **'Choose the instance for the account to add.'** + String get accountAddSubtitle; + + /// No description provided for @accountSignedIn. + /// + /// In en, this message translates to: + /// **'Signed in'** + String get accountSignedIn; + + /// No description provided for @accountAnonymous. + /// + /// In en, this message translates to: + /// **'Anonymous'** + String get accountAnonymous; + + /// No description provided for @accountRemoveTooltip. + /// + /// In en, this message translates to: + /// **'Remove account'** + String get accountRemoveTooltip; + + /// No description provided for @accountRemoveTitle. + /// + /// In en, this message translates to: + /// **'Remove account?'** + String get accountRemoveTitle; + + /// No description provided for @accountRemoveMessage. + /// + /// In en, this message translates to: + /// **'Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it. An anonymous account can\'t be signed into again.'** + String accountRemoveMessage(String name); + + /// No description provided for @accountRemoveConfirm. + /// + /// In en, this message translates to: + /// **'Remove'** + String get accountRemoveConfirm; + + /// No description provided for @sessionUnreachable. + /// + /// In en, this message translates to: + /// **'Couldn\'t reach the server.'** + String get sessionUnreachable; + + /// No description provided for @personDataFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open this device\'s data.'** + String get personDataFailed; + + /// No description provided for @startSignedOut. + /// + /// In en, this message translates to: + /// **'Signed out'** + String get startSignedOut; + + /// No description provided for @startOn. + /// + /// In en, this message translates to: + /// **'Start on {instance}:'** + String startOn(String instance); + + /// No description provided for @startWithGitHub. + /// + /// In en, this message translates to: + /// **'Sign in with GitHub'** + String get startWithGitHub; + + /// No description provided for @startWithEmail. + /// + /// In en, this message translates to: + /// **'Continue with email'** + String get startWithEmail; + + /// No description provided for @startWithoutAccount. + /// + /// In en, this message translates to: + /// **'Start without an account'** + String get startWithoutAccount; + + /// No description provided for @startAnonymous. + /// + /// In en, this message translates to: + /// **'Progress is saved on this device. Sign in to keep it.'** + String get startAnonymous; + + /// No description provided for @startGitHubFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t sign in with GitHub.'** + String get startGitHubFailed; + + /// No description provided for @startNoGitHubAccount. + /// + /// In en, this message translates to: + /// **'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.'** + String get startNoGitHubAccount; + + /// No description provided for @startOtherInstance. + /// + /// In en, this message translates to: + /// **'Use another instance'** + String get startOtherInstance; + + /// No description provided for @emailSignInTitle. + /// + /// In en, this message translates to: + /// **'Continue with email'** + String get emailSignInTitle; + + /// No description provided for @emailSignInIntro. + /// + /// In en, this message translates to: + /// **'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.'** + String get emailSignInIntro; + + /// No description provided for @emailSignInAddress. + /// + /// In en, this message translates to: + /// **'Email address'** + String get emailSignInAddress; + + /// No description provided for @emailSignInSend. + /// + /// In en, this message translates to: + /// **'Send code'** + String get emailSignInSend; + + /// No description provided for @emailSignInCodeSent. + /// + /// In en, this message translates to: + /// **'We mailed a code to {address}. It\'s valid for 15 minutes.'** + String emailSignInCodeSent(String address); + + /// No description provided for @emailSignInResent. + /// + /// In en, this message translates to: + /// **'A new code is on its way.'** + String get emailSignInResent; + + /// No description provided for @emailSignInCode. + /// + /// In en, this message translates to: + /// **'Code'** + String get emailSignInCode; + + /// No description provided for @emailSignInContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get emailSignInContinue; + + /// No description provided for @emailSignInResend. + /// + /// In en, this message translates to: + /// **'Send a new code'** + String get emailSignInResend; + + /// No description provided for @emailSignInOtherAddress. + /// + /// In en, this message translates to: + /// **'Use another address'** + String get emailSignInOtherAddress; + + /// No description provided for @emailSignInSwitchTitle. + /// + /// In en, this message translates to: + /// **'Switch to the account of {address}?'** + String emailSignInSwitchTitle(String address); + + /// No description provided for @emailSignInSwitchConfirm. + /// + /// In en, this message translates to: + /// **'Switch'** + String get emailSignInSwitchConfirm; + + /// No description provided for @emailSignInSwitchStay. + /// + /// In en, this message translates to: + /// **'Stay anonymous'** + String get emailSignInSwitchStay; + + /// No description provided for @emailSignInTaken. + /// + /// In en, this message translates to: + /// **'{address} already signs in to another account. To switch to it, sign out first.'** + String emailSignInTaken(String address); + + /// No description provided for @emailSignInUnavailable. + /// + /// In en, this message translates to: + /// **'This instance doesn\'t offer signing in with email.'** + String get emailSignInUnavailable; + + /// No description provided for @emailSignInNotAnAddress. + /// + /// In en, this message translates to: + /// **'This isn\'t an email address.'** + String get emailSignInNotAnAddress; + + /// No description provided for @emailSignInTooManyCodes. + /// + /// In en, this message translates to: + /// **'Too many codes in the last hour. Try again later.'** + String get emailSignInTooManyCodes; + + /// No description provided for @emailSignInWrongCode. + /// + /// In en, this message translates to: + /// **'That code isn\'t right.'** + String get emailSignInWrongCode; + + /// No description provided for @emailSignInTooManyTries. + /// + /// In en, this message translates to: + /// **'Too many wrong codes. Ask for a new one.'** + String get emailSignInTooManyTries; + + /// No description provided for @emailSignInCodeExpired. + /// + /// In en, this message translates to: + /// **'This code is no longer valid. Ask for a new one.'** + String get emailSignInCodeExpired; + + /// No description provided for @emailSignInOtherAddressLinked. + /// + /// In en, this message translates to: + /// **'You already sign in with another address.'** + String get emailSignInOtherAddressLinked; + + /// No description provided for @emailSignInMailFailed. + /// + /// In en, this message translates to: + /// **'The instance couldn\'t send the mail. Try again later.'** + String get emailSignInMailFailed; + + /// No description provided for @emailSignInFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t reach the instance. Try again.'** + String get emailSignInFailed; + + /// No description provided for @syncTitle. + /// + /// In en, this message translates to: + /// **'Sync'** + String get syncTitle; + + /// No description provided for @syncNow. + /// + /// In en, this message translates to: + /// **'Sync now'** + String get syncNow; + + /// No description provided for @syncStateSyncing. + /// + /// In en, this message translates to: + /// **'Syncing…'** + String get syncStateSyncing; + + /// No description provided for @syncStateSynced. + /// + /// In en, this message translates to: + /// **'Everything synced'** + String get syncStateSynced; + + /// No description provided for @syncStateFailing. + /// + /// In en, this message translates to: + /// **'Sync is failing'** + String get syncStateFailing; + + /// No description provided for @syncStateNotYet. + /// + /// In en, this message translates to: + /// **'Not synced yet'** + String get syncStateNotYet; + + /// No description provided for @syncStateMismatch. + /// + /// In en, this message translates to: + /// **'This app can\'t sync with {server}'** + String syncStateMismatch(String server); + + /// No description provided for @syncMismatchDetail. + /// + /// In en, this message translates to: + /// **'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.'** + String get syncMismatchDetail; + + /// No description provided for @syncLastCompleted. + /// + /// In en, this message translates to: + /// **'Last completed at {time}'** + String syncLastCompleted(String time); + + /// No description provided for @syncLastFailed. + /// + /// In en, this message translates to: + /// **'Last attempt failed at {time}'** + String syncLastFailed(String time); + + /// No description provided for @syncNoRunYet. + /// + /// In en, this message translates to: + /// **'No sync has completed since the app started'** + String get syncNoRunYet; + + /// No description provided for @syncPending. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}'** + String syncPending(int count); + + /// No description provided for @syncTroubleLabel. + /// + /// In en, this message translates to: + /// **'Sync needs attention'** + String get syncTroubleLabel; + + /// No description provided for @syncMismatchBanner. + /// + /// In en, this message translates to: + /// **'This app can\'t sync with {server} any more. Update it to keep syncing.'** + String syncMismatchBanner(String server); + + /// No description provided for @syncMismatchMore. + /// + /// In en, this message translates to: + /// **'More'** + String get syncMismatchMore; +} + +class _HalkyonAppLocalizationsDelegate + extends LocalizationsDelegate { + const _HalkyonAppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture( + lookupHalkyonAppLocalizations(locale), + ); + } + + @override + bool isSupported(Locale locale) => + ['de', 'en'].contains(locale.languageCode); + + @override + bool shouldReload(_HalkyonAppLocalizationsDelegate old) => false; +} + +HalkyonAppLocalizations lookupHalkyonAppLocalizations(Locale locale) { + // Lookup logic when language+country codes are specified. + switch (locale.languageCode) { + case 'en': + { + switch (locale.countryCode) { + case 'GB': + return HalkyonAppLocalizationsEnGb(); + case 'US': + return HalkyonAppLocalizationsEnUs(); + } + break; + } + } + + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return HalkyonAppLocalizationsDe(); + case 'en': + return HalkyonAppLocalizationsEn(); + } + + throw FlutterError( + 'HalkyonAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_localizations_de.dart b/packages/halkyon_app/lib/l10n/halkyon_app_localizations_de.dart new file mode 100644 index 0000000..1953721 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_localizations_de.dart @@ -0,0 +1,290 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; + +import 'halkyon_app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations { + HalkyonAppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get tryAgain => 'Erneut versuchen'; + + @override + String instanceWelcome(String app) { + return 'Willkommen bei $app'; + } + + @override + String get instanceBuiltInTagline => 'In der App voreingestellt'; + + @override + String get instanceAnother => 'Andere Instanz'; + + @override + String get instanceAnotherTagline => 'Gib ihre Adresse ein'; + + @override + String get instanceAddress => 'Adresse der Instanz'; + + @override + String get instanceAddressInvalid => 'Das ist keine Adresse.'; + + @override + String get instanceAddressRequired => 'Gib die Adresse einer Instanz ein.'; + + @override + String get instanceContinue => 'Weiter'; + + @override + String instanceUnreachable(String app, String name) { + return 'Unter $name antwortet keine Instanz von $app.'; + } + + @override + String instanceUseBuiltIn(String name) { + return '$name verwenden'; + } + + @override + String instanceOpenFailed(String name) { + return 'Die App auf $name konnte nicht geöffnet werden.'; + } + + @override + String instanceDataAndBackupsIn(String place) { + return 'Daten und Sicherungen: $place'; + } + + @override + String instanceDataIn(String place) { + return 'Daten: $place'; + } + + @override + String instanceBackupsIn(String place) { + return 'Sicherungen: $place'; + } + + @override + String get instanceNoBackups => 'Keine Sicherungen'; + + @override + String get instanceEuropeanUnion => 'Europäische Union'; + + @override + String get accountsTitle => 'Konten'; + + @override + String get accountAdd => 'Konto hinzufügen'; + + @override + String get accountAddSubtitle => + 'Wähle die Instanz für das Konto, das du hinzufügst.'; + + @override + String get accountSignedIn => 'Angemeldet'; + + @override + String get accountAnonymous => 'Anonym'; + + @override + String get accountRemoveTooltip => 'Konto entfernen'; + + @override + String get accountRemoveTitle => 'Konto entfernen?'; + + @override + String accountRemoveMessage(String name) { + return 'Entfernt das Konto auf $name von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf $name: Melde dich dort wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.'; + } + + @override + String get accountRemoveConfirm => 'Entfernen'; + + @override + String get sessionUnreachable => 'Der Server war nicht erreichbar.'; + + @override + String get personDataFailed => + 'Die Daten dieses Geräts konnten nicht geöffnet werden.'; + + @override + String get startSignedOut => 'Abgemeldet'; + + @override + String startOn(String instance) { + return 'Starte auf $instance:'; + } + + @override + String get startWithGitHub => 'Mit GitHub anmelden'; + + @override + String get startWithEmail => 'Mit E-Mail fortfahren'; + + @override + String get startWithoutAccount => 'Ohne Konto starten'; + + @override + String get startAnonymous => + 'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.'; + + @override + String get startGitHubFailed => 'Anmeldung mit GitHub fehlgeschlagen.'; + + @override + String get startNoGitHubAccount => + 'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Starte ohne Konto und füge GitHub dann im Profil hinzu.'; + + @override + String get startOtherInstance => 'Andere Instanz verwenden'; + + @override + String get emailSignInTitle => 'Mit E-Mail fortfahren'; + + @override + String get emailSignInIntro => + 'Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.'; + + @override + String get emailSignInAddress => 'E-Mail-Adresse'; + + @override + String get emailSignInSend => 'Code senden'; + + @override + String emailSignInCodeSent(String address) { + return 'Wir haben einen Code an $address geschickt. Er gilt 15 Minuten.'; + } + + @override + String get emailSignInResent => 'Ein neuer Code ist unterwegs.'; + + @override + String get emailSignInCode => 'Code'; + + @override + String get emailSignInContinue => 'Weiter'; + + @override + String get emailSignInResend => 'Neuen Code senden'; + + @override + String get emailSignInOtherAddress => 'Andere Adresse verwenden'; + + @override + String emailSignInSwitchTitle(String address) { + return 'Zum Konto von $address wechseln?'; + } + + @override + String get emailSignInSwitchConfirm => 'Wechseln'; + + @override + String get emailSignInSwitchStay => 'Anonym bleiben'; + + @override + String emailSignInTaken(String address) { + return 'Mit $address meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.'; + } + + @override + String get emailSignInUnavailable => + 'Diese Instanz bietet keine Anmeldung mit E-Mail an.'; + + @override + String get emailSignInNotAnAddress => 'Das ist keine E-Mail-Adresse.'; + + @override + String get emailSignInTooManyCodes => + 'Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.'; + + @override + String get emailSignInWrongCode => 'Dieser Code stimmt nicht.'; + + @override + String get emailSignInTooManyTries => + 'Zu viele falsche Codes. Fordere einen neuen an.'; + + @override + String get emailSignInCodeExpired => + 'Dieser Code gilt nicht mehr. Fordere einen neuen an.'; + + @override + String get emailSignInOtherAddressLinked => + 'Du meldest dich schon mit einer anderen Adresse an.'; + + @override + String get emailSignInMailFailed => + 'Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.'; + + @override + String get emailSignInFailed => + 'Die Instanz war nicht erreichbar. Versuche es noch einmal.'; + + @override + String get syncTitle => 'Synchronisierung'; + + @override + String get syncNow => 'Jetzt synchronisieren'; + + @override + String get syncStateSyncing => 'Wird synchronisiert…'; + + @override + String get syncStateSynced => 'Alles synchronisiert'; + + @override + String get syncStateFailing => 'Synchronisierung schlägt fehl'; + + @override + String get syncStateNotYet => 'Noch nicht synchronisiert'; + + @override + String syncStateMismatch(String server) { + return 'Diese App kann nicht mit $server synchronisieren'; + } + + @override + String get syncMismatchDetail => + 'Aktualisiere die App. Ist sie schon aktuell, muss der Server aktualisiert werden. Was du bis dahin tust, bleibt auf diesem Gerät und wird danach synchronisiert.'; + + @override + String syncLastCompleted(String time) { + return 'Zuletzt abgeschlossen um $time'; + } + + @override + String syncLastFailed(String time) { + return 'Letzter Versuch fehlgeschlagen um $time'; + } + + @override + String get syncNoRunYet => + 'Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen'; + + @override + String syncPending(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Änderungen warten auf die Synchronisierung', + one: '1 Änderung wartet auf die Synchronisierung', + ); + return '$_temp0'; + } + + @override + String get syncTroubleLabel => 'Synchronisierung benötigt Aufmerksamkeit'; + + @override + String syncMismatchBanner(String server) { + return 'Diese App kann nicht mehr mit $server synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.'; + } + + @override + String get syncMismatchMore => 'Mehr'; +} diff --git a/packages/halkyon_app/lib/l10n/halkyon_app_localizations_en.dart b/packages/halkyon_app/lib/l10n/halkyon_app_localizations_en.dart new file mode 100644 index 0000000..96d4a73 --- /dev/null +++ b/packages/halkyon_app/lib/l10n/halkyon_app_localizations_en.dart @@ -0,0 +1,297 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; + +import 'halkyon_app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations { + HalkyonAppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get tryAgain => 'Try again'; + + @override + String instanceWelcome(String app) { + return 'Welcome to $app'; + } + + @override + String get instanceBuiltInTagline => 'Comes with the app'; + + @override + String get instanceAnother => 'Another instance'; + + @override + String get instanceAnotherTagline => 'Enter its address'; + + @override + String get instanceAddress => 'Instance address'; + + @override + String get instanceAddressInvalid => 'This isn\'t an address.'; + + @override + String get instanceAddressRequired => 'Enter the address of an instance.'; + + @override + String get instanceContinue => 'Continue'; + + @override + String instanceUnreachable(String app, String name) { + return 'No instance of $app answers at $name.'; + } + + @override + String instanceUseBuiltIn(String name) { + return 'Use $name'; + } + + @override + String instanceOpenFailed(String name) { + return 'Couldn\'t open the app on $name.'; + } + + @override + String instanceDataAndBackupsIn(String place) { + return 'Data and backups: $place'; + } + + @override + String instanceDataIn(String place) { + return 'Data: $place'; + } + + @override + String instanceBackupsIn(String place) { + return 'Backups: $place'; + } + + @override + String get instanceNoBackups => 'No backups'; + + @override + String get instanceEuropeanUnion => 'European Union'; + + @override + String get accountsTitle => 'Accounts'; + + @override + String get accountAdd => 'Add account'; + + @override + String get accountAddSubtitle => + 'Choose the instance for the account to add.'; + + @override + String get accountSignedIn => 'Signed in'; + + @override + String get accountAnonymous => 'Anonymous'; + + @override + String get accountRemoveTooltip => 'Remove account'; + + @override + String get accountRemoveTitle => 'Remove account?'; + + @override + String accountRemoveMessage(String name) { + return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there again to have it. An anonymous account can\'t be signed into again.'; + } + + @override + String get accountRemoveConfirm => 'Remove'; + + @override + String get sessionUnreachable => 'Couldn\'t reach the server.'; + + @override + String get personDataFailed => 'Couldn\'t open this device\'s data.'; + + @override + String get startSignedOut => 'Signed out'; + + @override + String startOn(String instance) { + return 'Start on $instance:'; + } + + @override + String get startWithGitHub => 'Sign in with GitHub'; + + @override + String get startWithEmail => 'Continue with email'; + + @override + String get startWithoutAccount => 'Start without an account'; + + @override + String get startAnonymous => + 'Progress is saved on this device. Sign in to keep it.'; + + @override + String get startGitHubFailed => 'Couldn\'t sign in with GitHub.'; + + @override + String get startNoGitHubAccount => + 'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.'; + + @override + String get startOtherInstance => 'Use another instance'; + + @override + String get emailSignInTitle => 'Continue with email'; + + @override + String get emailSignInIntro => + 'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.'; + + @override + String get emailSignInAddress => 'Email address'; + + @override + String get emailSignInSend => 'Send code'; + + @override + String emailSignInCodeSent(String address) { + return 'We mailed a code to $address. It\'s valid for 15 minutes.'; + } + + @override + String get emailSignInResent => 'A new code is on its way.'; + + @override + String get emailSignInCode => 'Code'; + + @override + String get emailSignInContinue => 'Continue'; + + @override + String get emailSignInResend => 'Send a new code'; + + @override + String get emailSignInOtherAddress => 'Use another address'; + + @override + String emailSignInSwitchTitle(String address) { + return 'Switch to the account of $address?'; + } + + @override + String get emailSignInSwitchConfirm => 'Switch'; + + @override + String get emailSignInSwitchStay => 'Stay anonymous'; + + @override + String emailSignInTaken(String address) { + return '$address already signs in to another account. To switch to it, sign out first.'; + } + + @override + String get emailSignInUnavailable => + 'This instance doesn\'t offer signing in with email.'; + + @override + String get emailSignInNotAnAddress => 'This isn\'t an email address.'; + + @override + String get emailSignInTooManyCodes => + 'Too many codes in the last hour. Try again later.'; + + @override + String get emailSignInWrongCode => 'That code isn\'t right.'; + + @override + String get emailSignInTooManyTries => + 'Too many wrong codes. Ask for a new one.'; + + @override + String get emailSignInCodeExpired => + 'This code is no longer valid. Ask for a new one.'; + + @override + String get emailSignInOtherAddressLinked => + 'You already sign in with another address.'; + + @override + String get emailSignInMailFailed => + 'The instance couldn\'t send the mail. Try again later.'; + + @override + String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.'; + + @override + String get syncTitle => 'Sync'; + + @override + String get syncNow => 'Sync now'; + + @override + String get syncStateSyncing => 'Syncing…'; + + @override + String get syncStateSynced => 'Everything synced'; + + @override + String get syncStateFailing => 'Sync is failing'; + + @override + String get syncStateNotYet => 'Not synced yet'; + + @override + String syncStateMismatch(String server) { + return 'This app can\'t sync with $server'; + } + + @override + String get syncMismatchDetail => + 'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.'; + + @override + String syncLastCompleted(String time) { + return 'Last completed at $time'; + } + + @override + String syncLastFailed(String time) { + return 'Last attempt failed at $time'; + } + + @override + String get syncNoRunYet => 'No sync has completed since the app started'; + + @override + String syncPending(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count changes wait to sync', + one: '1 change waits to sync', + ); + return '$_temp0'; + } + + @override + String get syncTroubleLabel => 'Sync needs attention'; + + @override + String syncMismatchBanner(String server) { + return 'This app can\'t sync with $server any more. Update it to keep syncing.'; + } + + @override + String get syncMismatchMore => 'More'; +} + +/// The translations for English, as used in the United Kingdom (`en_GB`). +class HalkyonAppLocalizationsEnGb extends HalkyonAppLocalizationsEn { + HalkyonAppLocalizationsEnGb() : super('en_GB'); +} + +/// The translations for English, as used in the United States (`en_US`). +class HalkyonAppLocalizationsEnUs extends HalkyonAppLocalizationsEn { + HalkyonAppLocalizationsEnUs() : super('en_US'); +} diff --git a/packages/halkyon_app/lib/src/app/app_frame.dart b/packages/halkyon_app/lib/src/app/app_frame.dart new file mode 100644 index 0000000..9b09076 --- /dev/null +++ b/packages/halkyon_app/lib/src/app/app_frame.dart @@ -0,0 +1,96 @@ +import 'package:material_ui/material_ui.dart'; + +/// The device's choices this package's own screens follow while no person's +/// app shows: the theme mode, and the language (null: the system's). +typedef LookSettings = ({ThemeMode themeMode, Locale? locale}); + +/// What this package's own screens need to know of the app they are part +/// of: its name and look, and the few words only it can say. `InstanceGate` +/// hands it down ([AppFrameScope]) to every screen beneath, the person's +/// app included. +final class const AppFrame({ + /// The app's name in the language of the context it is asked in ("Halkyon + /// Learn"): the window's title, and where this package's texts name the + /// app. + required final String Function(BuildContext context) name, + + /// The app's word in its wordmark, under "Halkyon" ("Learn"). + required final String Function(BuildContext context) product, + + /// The app's icon, as the first start shows it on the tile of the + /// instance the build names. + required final ImageProvider icon, + + /// An instance's address as the address field's hint shows it + /// (`learn.example.org`). + required final String exampleAddress, + + /// What the first start says under the wordmark: what the instance chosen + /// keeps of the person's ("Choose the instance to keep your studysets + /// on."). + required final String Function(BuildContext context) firstStartSubtitle, + + /// What switching this device to another account leaves behind, as the + /// email sign-in says before it switches ("The studysets on this device + /// can't be combined with it yet: if you switch, they are deleted."). + required final String Function(BuildContext context) switchLeavesBehind, + + /// The app's theme in a [Brightness]. + required final ThemeData Function(Brightness brightness) theme, + + /// The app's localizations, this package's + /// (`HalkyonAppLocalizations.delegate`) and halkyon_ui's among them. + required final List> localizationsDelegates, + + /// The languages the app speaks. + required final List supportedLocales, + + /// Picks the app's language from the device's, as its own `MaterialApp` + /// does. + final LocaleListResolutionCallback? localeListResolutionCallback, + + /// What shows while the app opens. + required final Widget launchScreen, +}); + +/// Hands the [AppFrame] down to every screen beneath it. +class const AppFrameScope({ + /// The app's frame. + required final AppFrame frame, + required super.child, + super.key, +}) extends InheritedWidget { + /// The frame above [context]. + static AppFrame of(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + assert(scope != null, 'No AppFrameScope above this widget.'); + return scope!.frame; + } + + @override + bool updateShouldNotify(AppFrameScope oldWidget) => frame != oldWidget.frame; +} + +/// The app's look around a single screen of this package's, while no +/// person's app shows, as [look] says. +class const PlainApp({ + required final LookSettings look, + required final Widget home, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final frame = AppFrameScope.of(context); + return MaterialApp( + onGenerateTitle: frame.name, + theme: frame.theme(Brightness.light), + darkTheme: frame.theme(Brightness.dark), + themeMode: look.themeMode, + locale: look.locale, + localeListResolutionCallback: frame.localeListResolutionCallback, + localizationsDelegates: frame.localizationsDelegates, + supportedLocales: frame.supportedLocales, + home: home, + ); + } +} diff --git a/packages/halkyon_app/lib/src/app/app_scope.dart b/packages/halkyon_app/lib/src/app/app_scope.dart new file mode 100644 index 0000000..5aabe4b --- /dev/null +++ b/packages/halkyon_app/lib/src/app/app_scope.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/data/signed_out_mark.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; + +/// What can be closed once it is no longer needed: an app's dependencies on +/// an instance, or a person's. +abstract interface class Disposable() { + /// Closes it; nothing of it is used afterwards. + Future dispose(); +} + +/// What this package needs of an app's dependencies on one instance (its +/// composition root), which `InstanceGate` opens for the instance in use and +/// [AppScope] hands down. An app's own class implements it beside its +/// features' repositories. +abstract interface class AppDependencies

() + implements Disposable { + /// The instance's name as people know it (its host), for messages about + /// it. + String get serverName; + + /// Who is signed in, and signing in and out. + AuthRepository get auth; + + /// Whether the person here signed out, so the app asks how to start. + SignedOutMark get signedOut; + + /// Opens the dependencies of [person], once the previous person's have + /// closed; `PersonScope` disposes them when the person goes. + Future

openPerson(SignedIn person); +} + +/// Hands an app's [dependencies] on the instance in use down the widget +/// tree; `InstanceGate` puts it above the app. +class const AppScope({ + /// The app's dependencies. + required final AppDependencies dependencies, + required super.child, + super.key, +}) extends InheritedWidget { + /// The dependencies above [context], as the app's own type [D] + /// (`AppScope.of(context)`), or as [AppDependencies] where + /// that is all that is needed. + /// + /// Doesn't register [context] for rebuilds: the dependencies of one app run + /// never change. + static D of>(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + assert(scope != null, 'No AppScope above this widget.'); + return scope!.dependencies as D; + } + + @override + bool updateShouldNotify(AppScope oldWidget) => + dependencies != oldWidget.dependencies; +} diff --git a/packages/halkyon_app/lib/src/app/instance_gate.dart b/packages/halkyon_app/lib/src/app/instance_gate.dart new file mode 100644 index 0000000..6699433 --- /dev/null +++ b/packages/halkyon_app/lib/src/app/instance_gate.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/instance/country_names.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_checker.dart'; +import 'package:halkyon_app/src/instance/instance_choice.dart'; +import 'package:halkyon_app/src/instance/instance_scope.dart'; +import 'package:halkyon_app/src/instance/widget/first_start_screen.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The app on the instance in use ([InstanceChoice]): on a new install +/// first the choice of instance ([FirstStartScreen]), then [app] on its +/// dependencies ([AppScope]), opened by [open] anew whenever the person +/// switches, the previous ones closed first. Above it all, [frame] +/// ([AppFrameScope]). +/// +/// [app] is built anew for every instance (keyed by it), so nothing of one +/// instance's app reaches another's. +class const InstanceGate({ + /// The app's name, look and own words, for this package's screens. + required final AppFrame frame, + + required final InstanceChoice choice, + + /// Checks an address before the app moves there. + required final InstanceChecker checker, + + /// Names the countries instances keep their data in. + required final CountryNames countryNames, + + /// The dependencies of the app on an instance. + required final Future> Function( + Instance instance, + ) + open, + + /// Reads the device's theme mode and language, for the look while no + /// instance's app shows; read anew as each instance opens, as they may + /// have changed on another. + required final Future Function() readLook, + + /// The app on the instance in use. + required final Widget app, + super.key, +}) extends StatefulWidget { + @override + State createState() => _InstanceGateState(); +} + +class _InstanceGateState() extends State { + Instance? _opened; + AppDependencies? _dependencies; + var _failed = false; + LookSettings _look = (themeMode: ThemeMode.system, locale: null); + + /// The last switch, which the next one waits for. + var _switching = Future.value(); + + @override + void initState() { + super.initState(); + widget.choice.addListener(_onChoice); + unawaited(_readLook()); + _onChoice(); + } + + @override + void dispose() { + widget.choice.removeListener(_onChoice); + unawaited(_dependencies?.dispose()); + super.dispose(); + } + + Future _readLook() async { + final look = await widget.readLook(); + if (mounted) { + setState(() => _look = look); + } + } + + void _onChoice() { + final current = widget.choice.current; + if (current != _opened) { + _switching = _switching.then((_) => _open(current)); + } + } + + /// Closes what is open, then opens [instance]'s app; nothing when it is + /// open already, unless [again], after a failure. + Future _open(Instance? instance, {bool again = false}) async { + if (!mounted || (instance == _opened && !again)) { + return; + } + final previous = _dependencies; + setState(() { + _opened = instance; + _dependencies = null; + _failed = false; + }); + await previous?.dispose(); + if (instance == null) { + return; + } + await _readLook(); + try { + final dependencies = await widget.open(instance); + if (!mounted || _opened != instance) { + await dependencies.dispose(); + return; + } + setState(() => _dependencies = dependencies); + } on Object catch (error, st) { + log( + 'Opening $instance failed', + name: 'Instances', + error: error, + stackTrace: st, + ); + if (mounted && _opened == instance) { + setState(() => _failed = true); + } + } + } + + @override + Widget build(BuildContext context) { + final current = widget.choice.current; + final dependencies = _dependencies; + return AppFrameScope( + frame: widget.frame, + child: InstanceScope( + notifier: widget.choice, + checker: widget.checker, + countryNames: widget.countryNames, + child: switch (current) { + null => PlainApp(look: _look, home: const FirstStartScreen()), + final instance when dependencies != null && _opened == instance => + AppScope( + key: ValueKey(instance), + dependencies: dependencies, + child: widget.app, + ), + final instance when _failed && _opened == instance => PlainApp( + look: _look, + home: _OpenFailed( + instance: instance, + onRetry: () => _switching = _switching.then( + (_) => _open(instance, again: true), + ), + ), + ), + // Opening, or closing the one before. + _ => PlainApp(look: _look, home: widget.frame.launchScreen), + }, + ), + ); + } +} + +class const _OpenFailed({ + required final Instance instance, + required final VoidCallback onRetry, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.instanceOpenFailed(instance.name)), + const SizedBox(height: 16), + FilledButton(onPressed: onRetry, child: Text(l10n.tryAgain)), + ], + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/app/person_scope.dart b/packages/halkyon_app/lib/src/app/person_scope.dart new file mode 100644 index 0000000..ab95511 --- /dev/null +++ b/packages/halkyon_app/lib/src/app/person_scope.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Opens [person]'s dependencies (`AppDependencies.openPerson`), shows +/// [child] with them, and disposes them when it goes away. `SessionGate` +/// gives every person a new `PersonScope`. +class const PersonScope({ + /// Whose dependencies these are. + required final SignedIn person, + + /// What the person sees. + required final Widget child, + super.key, +}) extends StatefulWidget { + /// The dependencies of the person signed in above [context], as the app's + /// own type [P] (`PersonScope.of(context)`). + static P of

(BuildContext context) { + final scope = context + .getInheritedWidgetOfExactType<_PersonDependenciesScope>(); + assert(scope != null, 'No PersonScope above this widget.'); + return scope!.dependencies as P; + } + + @override + State createState() => _PersonScopeState(); +} + +class _PersonScopeState() extends State { + Future? _opening; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _opening ??= AppScope.of(context).openPerson(widget.person); + } + + @override + void dispose() { + unawaited( + _opening?.then( + (dependencies) => dependencies.dispose(), + // Opening failed and said so on screen; there is nothing to close. + onError: (Object _) {}, + ), + ); + super.dispose(); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _opening, + builder: (context, snapshot) => switch (snapshot) { + AsyncSnapshot(:final data?) => _PersonDependenciesScope( + dependencies: data, + child: widget.child, + ), + AsyncSnapshot(hasError: true) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(HalkyonAppLocalizations.of(context).personDataFailed), + const SizedBox(height: 16), + FilledButton( + onPressed: () => setState(() { + _opening = AppScope.of(context).openPerson(widget.person); + }), + child: Text(HalkyonAppLocalizations.of(context).tryAgain), + ), + ], + ), + ), + ), + _ => AppFrameScope.of(context).launchScreen, + }, + ); +} + +class const _PersonDependenciesScope({ + required final Disposable dependencies, + required super.child, +}) extends InheritedWidget { + @override + bool updateShouldNotify(_PersonDependenciesScope oldWidget) => + dependencies != oldWidget.dependencies; +} diff --git a/packages/halkyon_app/lib/src/auth/bloc/email_sign_in_bloc.dart b/packages/halkyon_app/lib/src/auth/bloc/email_sign_in_bloc.dart new file mode 100644 index 0000000..1ec313c --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/bloc/email_sign_in_bloc.dart @@ -0,0 +1,258 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; + +/// Signs in with an email address, by a code mailed to it, as signing in +/// with GitHub does: a new address becomes the person's, who +/// keeps everything; an address that is another person's switches this +/// device to them, right away when it holds nothing +/// (`deviceHoldsContent`), otherwise once the person confirms that this +/// device's content stays behind. Someone identified is never switched: +/// they are told the address is another account's, as their own account +/// would otherwise be left. +class EmailSignInBloc( + final AuthRepository _auth, { + + /// Whether this device holds content of the person signed in that a + /// switch would leave behind, as the app counts it (Halkyon Learn: its + /// studysets, those waiting in Recently deleted too). + required final Future Function() _deviceHoldsContent, + + /// The language the mail is worded in (`en`, `de`). + required final String language, +}) extends Bloc { + this : super(const EmailSignInStateAddress()) { + on(_onCodeRequested); + on(_onCodeEntered); + on(_onCodeResent); + on( + (event, emit) => emit(const EmailSignInStateAddress()), + ); + on(_onSwitchConfirmed); + on( + (event, emit) => emit(const EmailSignInStateAddress()), + ); + } + + Future _onCodeRequested( + EmailSignInEventCodeRequested event, + Emitter emit, + ) async { + // As the instance takes it, and as the person reads it back. + final address = event.address.trim().toLowerCase(); + emit(const EmailSignInStateAddress(sending: true)); + try { + emit( + EmailSignInStateCode( + address: address, + request: await _auth.sendEmailCode(address, language: language), + ), + ); + } on Exception catch (error, st) { + emit(EmailSignInStateAddress(problem: _problemOf(error, st))); + } + } + + Future _onCodeResent( + EmailSignInEventCodeResent event, + Emitter emit, + ) async { + if (state case final EmailSignInStateCode code) { + emit(code.checked()); + try { + emit( + EmailSignInStateCode( + address: code.address, + request: await _auth.sendEmailCode( + code.address, + language: language, + ), + resent: true, + ), + ); + } on Exception catch (error, st) { + emit(code.failed(_problemOf(error, st))); + } + } + } + + Future _onCodeEntered( + EmailSignInEventCodeEntered event, + Emitter emit, + ) async { + final code = state; + if (code is! EmailSignInStateCode) { + return; + } + emit(code.checked()); + try { + switch (await _auth.verifyEmailCode( + code.request, + event.code, + address: code.address, + )) { + case EmailLinked(): + emit(EmailSignInStateLinked(address: code.address)); + case EmailTakenByAnotherPerson() when _auth.isIdentified: + emit(EmailSignInStateTaken(address: code.address)); + case EmailTakenByAnotherPerson(:final switchToken): + if (await _deviceHoldsContent()) { + emit( + EmailSignInStateConfirmSwitch( + address: code.address, + switchToken: switchToken, + ), + ); + } else { + await _switch(code.address, switchToken, emit); + } + } + } on Exception catch (error, st) { + emit(code.failed(_problemOf(error, st))); + } + } + + Future _onSwitchConfirmed( + EmailSignInEventSwitchConfirmed event, + Emitter emit, + ) async { + if (state case EmailSignInStateConfirmSwitch( + :final address, + :final switchToken, + )) { + try { + await _switch(address, switchToken, emit); + } on Exception catch (error, st) { + emit(EmailSignInStateAddress(problem: _problemOf(error, st))); + } + } + } + + // After a switch, the previous person's whole subtree goes away, this bloc + // with it, so nothing is emitted then. + Future _switch( + String address, + String switchToken, + Emitter emit, + ) async { + emit(const EmailSignInStateSwitching()); + await _auth.switchToEmail(switchToken, address: address); + } + + EmailSignInProblem _problemOf(Exception error, StackTrace st) { + if (error is EmailSignInFailed) { + return error.problem; + } + addError(error, st); + return EmailSignInProblem.failed; + } +} + +/// What happens to signing in with an email address. +sealed class const EmailSignInEvent() with Equatable { + @override + List get props => const []; +} + +/// Mail a code to [address]. +final class const EmailSignInEventCodeRequested(final String address) + extends EmailSignInEvent { + @override + List get props => [address]; +} + +/// The person typed [code], from the mail. +final class const EmailSignInEventCodeEntered(final String code) + extends EmailSignInEvent { + @override + List get props => [code]; +} + +/// Mail a new code to the same address. +final class const EmailSignInEventCodeResent() extends EmailSignInEvent; + +/// Type another address. +final class const EmailSignInEventAddressReopened() extends EmailSignInEvent; + +/// Switch to the address's person; this device's content stays behind. +final class const EmailSignInEventSwitchConfirmed() extends EmailSignInEvent; + +/// Stay anonymous. +final class const EmailSignInEventSwitchDeclined() extends EmailSignInEvent; + +/// Signing in with an email address, as its sheet shows it. +sealed class const EmailSignInState() with Equatable { + @override + List get props => const []; +} + +/// Typing the address. +final class const EmailSignInStateAddress({ + /// What went wrong the last time. + final EmailSignInProblem? problem, + + /// Whether the code is being mailed. + final bool sending = false, +}) extends EmailSignInState { + @override + List get props => [problem, sending]; +} + +/// Typing the code mailed to [address] for [request]. +final class const EmailSignInStateCode({ + required final String address, + required final String request, + + /// What went wrong the last time. + final EmailSignInProblem? problem, + + /// Whether the instance is being asked: about the code, or for a new one. + final bool checking = false, + + /// Whether a new code was mailed on request. + final bool resent = false, +}) extends EmailSignInState { + /// This, while the instance is asked. + EmailSignInStateCode checked() => + EmailSignInStateCode(address: address, request: request, checking: true); + + /// This, once [problem] came back. + EmailSignInStateCode failed(EmailSignInProblem problem) => + EmailSignInStateCode( + address: address, + request: request, + problem: problem, + ); + + @override + List get props => [address, request, problem, checking, resent]; +} + +/// The address is another person's and this device holds content: switch +/// and leave it behind, or stay anonymous? +final class const EmailSignInStateConfirmSwitch({ + required final String address, + required final String switchToken, +}) extends EmailSignInState { + @override + List get props => [address, switchToken]; +} + +/// Switching this device to the address's person. +final class const EmailSignInStateSwitching() extends EmailSignInState; + +/// The address is another account's, and the person signed in is +/// identified: nothing changed. +final class const EmailSignInStateTaken({required final String address}) + extends EmailSignInState { + @override + List get props => [address]; +} + +/// The address is now the person's. +final class const EmailSignInStateLinked({required final String address}) + extends EmailSignInState { + @override + List get props => [address]; +} diff --git a/packages/halkyon_app/lib/src/auth/bloc/github_sign_in_bloc.dart b/packages/halkyon_app/lib/src/auth/bloc/github_sign_in_bloc.dart new file mode 100644 index 0000000..3ef3075 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/bloc/github_sign_in_bloc.dart @@ -0,0 +1,173 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; + +/// Signs an anonymous person in with GitHub, keeping everything they have, +/// or adds GitHub as a way in for someone who signs in with an email +/// address. +/// +/// If the GitHub account already belongs to another person (typically their +/// account from another device), this device switches to that person: right +/// away when it holds nothing (`deviceHoldsContent`), otherwise only once +/// the person confirms that this device's content stays behind. Someone +/// identified is never switched, as their own account would be left: they +/// are told the GitHub account is another's. +class GitHubSignInBloc( + final AuthRepository _auth, { + + /// Whether this device holds content of the person signed in that a + /// switch would leave behind, as the app counts it (Halkyon Learn: its + /// studysets, those waiting in Recently deleted too). + required final Future Function() _deviceHoldsContent, +}) extends Bloc { + this : super(const GitHubSignInStateChecking()) { + on(_onStarted); + on(_onRequested); + on(_onSwitchConfirmed); + on( + (event, emit) => emit(const GitHubSignInStateReady()), + ); + // The sign-in in progress then ends as cancelled. + on((event, emit) => _auth.cancelGitHub()); + } + + /// Closing the account dialog cancels a sign-in still waiting for GitHub's + /// page: nothing finishes without the dialog to show it, such as the + /// question whether to switch. + @override + Future close() async { + if (state is GitHubSignInStateBusy) { + await _auth.cancelGitHub(); + } + await super.close(); + } + + Future _onStarted( + GitHubSignInEventStarted event, + Emitter emit, + ) async { + try { + emit( + await _auth.canSignInWithGitHub() + ? const GitHubSignInStateReady() + : const GitHubSignInStateUnavailable(), + ); + } on Exception catch (error, st) { + // Without the instance, signing in couldn't work either. + addError(error, st); + emit(const GitHubSignInStateUnavailable()); + } + } + + Future _onRequested( + GitHubSignInEventRequested event, + Emitter emit, + ) async { + emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub)); + try { + switch (await _auth.linkGitHub()) { + case GitHubLinkOutcome.linked: + emit(const GitHubSignInStateLinked()); + case GitHubLinkOutcome.cancelled: + emit(const GitHubSignInStateReady()); + case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified: + emit(const GitHubSignInStateTaken()); + case GitHubLinkOutcome.takenByAnotherPerson: + if (await _deviceHoldsContent()) { + emit(const GitHubSignInStateConfirmSwitch()); + } else { + await _switch(emit); + } + } + } on Exception catch (error, st) { + addError(error, st); + emit(const GitHubSignInStateReady(failed: true)); + } + } + + Future _onSwitchConfirmed( + GitHubSignInEventSwitchConfirmed event, + Emitter emit, + ) async { + emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub)); + try { + await _switch(emit); + } on Exception catch (error, st) { + addError(error, st); + emit(const GitHubSignInStateReady(failed: true)); + } + } + + // After a switch, the previous person's whole subtree goes away, this bloc + // with it, so nothing is emitted then. + Future _switch(Emitter emit) async { + if (!await _auth.switchToGitHub()) { + emit(const GitHubSignInStateReady()); + } + } +} + +/// What happens to signing in with GitHub. +sealed class const GitHubSignInEvent() with Equatable { + @override + List get props => const []; +} + +/// The account dialog opens: does this instance offer GitHub sign-in? +final class const GitHubSignInEventStarted() extends GitHubSignInEvent; + +/// Sign in with GitHub. +final class const GitHubSignInEventRequested() extends GitHubSignInEvent; + +/// Switch to the GitHub account's person; this device's content stays +/// behind. +final class const GitHubSignInEventSwitchConfirmed() extends GitHubSignInEvent; + +/// Stay anonymous. +final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent; + +/// Stop waiting for GitHub's page in the browser. +final class const GitHubSignInEventCancelled() extends GitHubSignInEvent; + +/// Signing in with GitHub, as the account dialog shows it. +sealed class const GitHubSignInState() with Equatable { + @override + List get props => const []; +} + +/// Asking the instance whether it offers GitHub sign-in. +final class const GitHubSignInStateChecking() extends GitHubSignInState; + +/// This instance has no GitHub sign-in, or can't be reached. +final class const GitHubSignInStateUnavailable() extends GitHubSignInState; + +/// Ready to sign in with GitHub. +final class const GitHubSignInStateReady({ + /// Whether the last attempt failed. + final bool failed = false, +}) extends GitHubSignInState { + @override + List get props => [failed]; +} + +/// Signing in: GitHub's page is open, or the server is being asked. +final class const GitHubSignInStateBusy({ + /// Whether the app offers to cancel, where no sign-in screen of the system + /// does. A cancel only stops the wait for GitHub's page. + final bool cancellable = false, +}) extends GitHubSignInState { + @override + List get props => [cancellable]; +} + +/// The GitHub account belongs to another person and this device holds +/// content: switch and leave it behind, or stay anonymous? +final class const GitHubSignInStateConfirmSwitch() extends GitHubSignInState; + +/// Linked: the person now signs in with GitHub. +final class const GitHubSignInStateLinked() extends GitHubSignInState; + +/// The GitHub account is another account's, and the person signed in is +/// identified: nothing changed. +final class const GitHubSignInStateTaken() extends GitHubSignInState; diff --git a/packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart b/packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart new file mode 100644 index 0000000..a75484c --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart @@ -0,0 +1,249 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/data/signed_out_mark.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/model/github_sign_in.dart'; + +/// Keeps a person signed in: follows who is signed in, signs in anonymously +/// at a first start, and signs out on request. Once signed out (by the +/// person, by deleting the account, or by the server ending the session), +/// it asks how to start rather than making an account by itself, also at +/// every start of the app until someone starts. +class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark) + extends Bloc { + this : super(const SessionStateLoading()) { + on(_onStarted); + on(_onSignInRequested); + on(_onStartedAnonymously); + on(_onGitHubRequested); + on(_onEmailRequested); + on( + (event, emit) => emit(SessionStateSignedOut(problem: event.problem)), + ); + on(_onSignOutRequested); + on(_onDeleteRequested); + } + + /// Whether an email sign-in follows the anonymous start under way. + var _thenEmail = false; + + Future _onStarted( + SessionEventStarted event, + Emitter emit, + ) async { + if (_auth.status is SignedOut && await _mark.read()) { + emit(const SessionStateSignedOut()); + } else { + add(const SessionEventSignInRequested()); + } + await emit.forEach( + _auth.statusChanges, + onData: (status) { + if (status is SignedOut) { + // Signed out, here or by the server (a revoked session): ask how + // to start, now and at the next starts. + unawaited(_mark.write(signedOut: true)); + return const SessionStateSignedOut(); + } + unawaited(_mark.write(signedOut: false)); + return _stateOf(status, signsInWithEmail: _thenEmail); + }, + ); + } + + Future _onSignInRequested( + SessionEventSignInRequested event, + Emitter emit, + ) async { + // A session stored on the device wins: never a second anonymous account. + if (_auth.status case final SignedIn person) { + emit(SessionStateActive(person)); + return; + } + emit(const SessionStateLoading()); + // Read before signing in: the status change on the way shows the app + // too, and must ask for the address as well. + final email = _thenEmail; + try { + await _auth.ensureSignedIn(); + _thenEmail = false; + emit(_stateOf(_auth.status, signsInWithEmail: email)); + } on Object catch (error, st) { + _thenEmail = false; + addError(error, st); + emit(const SessionStateFailed()); + } + } + + Future _onStartedAnonymously( + SessionEventStartedAnonymously event, + Emitter emit, + ) async { + await _mark.write(signedOut: false); + add(const SessionEventSignInRequested()); + } + + /// Signing in with GitHub needs no account here first: the GitHub + /// account's person, if there is one. + Future _onGitHubRequested( + SessionEventGitHubRequested event, + Emitter emit, + ) async { + emit(const SessionStateSignedOut(busy: true)); + try { + if (!await _auth.switchToGitHub()) { + emit(const SessionStateSignedOut()); + } + // Signed in: the status change shows the person's app. + } on GitHubSignInFailed catch (e) { + emit( + SessionStateSignedOut( + problem: e.problem == GitHubSignInProblem.unknownAccount + ? SignedOutProblem.noGitHubAccount + : SignedOutProblem.failed, + ), + ); + } on Object catch (error, st) { + addError(error, st); + emit(const SessionStateSignedOut(problem: SignedOutProblem.failed)); + } + } + + /// Email codes are only taken from someone signed in: an anonymous start + /// first, then the address, which switches to its person if it has one. + Future _onEmailRequested( + SessionEventEmailRequested event, + Emitter emit, + ) async { + _thenEmail = true; + await _mark.write(signedOut: false); + add(const SessionEventSignInRequested()); + } + + Future _onSignOutRequested( + SessionEventSignOutRequested event, + Emitter emit, + ) async { + emit(const SessionStateLoading()); + try { + // The status change to signed out then asks how to start. + await _auth.signOut(); + } on Object catch (error, st) { + addError(error, st); + emit(_stateOf(_auth.status)); + } + } + + Future _onDeleteRequested( + SessionEventDeleteRequested event, + Emitter emit, + ) async { + emit(const SessionStateLoading()); + try { + // The status change to signed out then asks how to start. + await _auth.deleteAccount(); + } on Object catch (error, st) { + addError(error, st); + emit(_stateOf(_auth.status)); + } + } + + SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) => + switch (status) { + SignedIn() => SessionStateActive( + status, + signsInWithEmail: signsInWithEmail, + ), + SignedOut() => const SessionStateSignedOut(), + }; +} + +/// What happens to the session. +sealed class const SessionEvent() with Equatable { + @override + List get props => const []; +} + +/// The app starts: follow who is signed in, and sign in anonymously if nobody +/// is. +final class const SessionEventStarted() extends SessionEvent; + +/// Sign in (again): after a failed attempt, or once nobody is signed in. +final class const SessionEventSignInRequested() extends SessionEvent; + +/// Start without an account, once signed out: a new anonymous one. +final class const SessionEventStartedAnonymously() extends SessionEvent; + +/// Sign in with GitHub, once signed out. +final class const SessionEventGitHubRequested() extends SessionEvent; + +/// Sign in with an email address, once signed out. +final class const SessionEventEmailRequested() extends SessionEvent; + +/// Signed out, [problem] if any saying why the last way to start failed. +final class const SessionEventSignedOut({final SignedOutProblem? problem}) + extends SessionEvent { + @override + List get props => [problem]; +} + +/// The person confirmed that they want to sign out. +final class const SessionEventSignOutRequested() extends SessionEvent; + +/// Delete the signed-in person with everything they have; the person has +/// confirmed it. +final class const SessionEventDeleteRequested() extends SessionEvent; + +/// Whether the app has a signed-in person to work for. +sealed class const SessionState() with Equatable; + +/// Signing in, or finding out who is signed in. +final class const SessionStateLoading() extends SessionState { + @override + List get props => const []; +} + +/// Signing in failed, for example without a connection; the app offers to try +/// again. +final class const SessionStateFailed() extends SessionState { + @override + List get props => const []; +} + +/// A person is signed in. +final class const SessionStateActive( + /// Who is signed in. + final SignedIn person, { + + /// Whether they asked to sign in with an email address, which their app + /// asks for once it opens. + final bool signsInWithEmail = false, +}) extends SessionState { + @override + List get props => [person, signsInWithEmail]; +} + +/// Nobody is signed in since signing out: the app asks how to start. +final class const SessionStateSignedOut({ + /// Whether a way to start is under way. + final bool busy = false, + + /// Why the last way to start failed; null for none. + final SignedOutProblem? problem, +}) extends SessionState { + @override + List get props => [busy, problem]; +} + +/// Why a way to start failed. +enum SignedOutProblem() { + /// No account has the GitHub account yet: start without one, then add + /// GitHub. + noGitHubAccount, + + /// Anything else, as no connection. + failed, +} diff --git a/packages/halkyon_app/lib/src/auth/bloc/sign_in_ways_cubit.dart b/packages/halkyon_app/lib/src/auth/bloc/sign_in_ways_cubit.dart new file mode 100644 index 0000000..f2ea884 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/bloc/sign_in_ways_cubit.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/model/sign_in_ways.dart'; + +/// What the account card shows of signing in: whether this instance offers +/// email sign-in, how the person signs in, and the address they signed in +/// with on this device ([load] again after either changes). +class SignInWaysCubit(final AuthRepository _auth) + extends Cubit { + this : super(const SignInWaysState()); + + /// Reads it all anew. + Future load() async { + try { + final offersEmail = await _auth.canSignInWithEmail(); + final ways = _auth.isIdentified + ? await _auth.signInWays() + : SignInWays.none; + final address = await _auth.emailAddress(); + if (!isClosed) { + emit( + SignInWaysState( + loaded: true, + offersEmail: offersEmail, + ways: ways, + emailAddress: address, + ), + ); + } + } on Exception catch (error, st) { + // Without the instance, nothing more is offered. + addError(error, st); + if (!isClosed) { + emit(const SignInWaysState(loaded: true)); + } + } + } +} + +/// What [SignInWaysCubit] read. +final class const SignInWaysState({ + /// Whether it was read at all. + final bool loaded = false, + + /// Whether this instance offers signing in with an email address. + final bool offersEmail = false, + + /// How the person signs in. + final SignInWays ways = SignInWays.none, + + /// The address the person signed in with on this device. + final String? emailAddress, +}) with Equatable { + @override + List get props => [loaded, offersEmail, ways, emailAddress]; +} diff --git a/packages/halkyon_app/lib/src/auth/data/auth_gateway.dart b/packages/halkyon_app/lib/src/auth/data/auth_gateway.dart new file mode 100644 index 0000000..4b9bab8 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/auth_gateway.dart @@ -0,0 +1,65 @@ +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; +import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; +import 'package:halkyon_app/src/auth/model/sign_in_ways.dart'; + +/// The boundary to the server's authentication: who is signed in, and signing +/// in and out. Tests replace it with a fake. +abstract interface class AuthGateway() { + /// Who is signed in right now. + AuthStatus get status; + + /// Every later change of [status]. + Stream get statusChanges; + + /// Creates an anonymous account on the server and signs in with it. + Future signInAnonymously(); + + /// Signs out on this device. + Future signOut(); + + /// Deletes the signed-in person with everything they have, on the server + /// and so on all their devices, and forgets the session here. + Future deleteAccount(); + + /// The client ID of this instance's GitHub OAuth app; null when GitHub + /// sign-in is off on this instance. + Future gitHubClientId(); + + /// Authorizes with GitHub in the browser and links the GitHub account to the + /// signed-in person: the lossless upgrade from an anonymous account. + Future linkGitHub(); + + /// Authorizes with GitHub again and switches this device to the person who + /// has that GitHub account; false when the person cancelled on GitHub. + Future switchToGitHub(); + + /// Whether the app itself waits for GitHub's page in the browser, so that + /// only the app can cancel it (Linux, Windows). Elsewhere the system's + /// sign-in screen has its own Cancel. + bool get canCancelGitHub; + + /// Stops waiting for GitHub's page in the browser: the [linkGitHub] or + /// [switchToGitHub] in progress ends as cancelled. Does nothing once + /// GitHub has answered. + Future cancelGitHub(); + + /// Whether this instance offers signing in with an email address. + Future offersEmailSignIn(); + + /// Mails a code to [address], worded in [language] (`en`, `de`), and + /// returns the request it answers. Throws [EmailSignInFailed]. + Future sendEmailCode(String address, {required String language}); + + /// Accepts [code] for [request]: the address becomes the signed-in + /// person's, the lossless upgrade from an anonymous account, unless it is + /// another person's. Throws [EmailSignInFailed]. + Future verifyEmailCode(String request, String code); + + /// Switches this device to the person [switchToken] signs in as, and + /// returns them (their `AuthUser` id). Throws [EmailSignInFailed]. + Future switchToEmail(String switchToken); + + /// How the signed-in person signs in. + Future signInWays(); +} diff --git a/packages/halkyon_app/lib/src/auth/data/auth_repository.dart b/packages/halkyon_app/lib/src/auth/data/auth_repository.dart new file mode 100644 index 0000000..a71559c --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/auth_repository.dart @@ -0,0 +1,114 @@ +import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; +import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; +import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; +import 'package:halkyon_app/src/auth/model/sign_in_ways.dart'; + +/// Who is signed in, and signing in and out, as the features use it. +class AuthRepository( + final AuthGateway _gateway, + final SignInAddresses _addresses, +) { + /// Who is signed in right now. + AuthStatus get status => _gateway.status; + + /// Every later change of [status]. + Stream get statusChanges => _gateway.statusChanges; + + /// Whether the signed-in person has an identity, as GitHub or an email + /// address. + bool get isIdentified => switch (_gateway.status) { + SignedIn(:final isIdentified) => isIdentified, + SignedOut() => false, + }; + + /// Signs in anonymously unless someone is signed in already, so the app + /// works from the first launch without any sign-up. + Future ensureSignedIn() async { + if (_gateway.status is SignedOut) { + await _gateway.signInAnonymously(); + } + } + + /// Signs out on this device. An anonymous account can never be signed into + /// again, so signing out of one deletes it with its progress (decided + /// 2026-09-30); the UI warns first. + Future signOut() => switch (_gateway.status) { + SignedIn(isIdentified: false) => _gateway.deleteAccount(), + _ => _gateway.signOut(), + }; + + /// Deletes the signed-in person with everything they have, on the server + /// and all their devices; the UI asks first. + Future deleteAccount() => _gateway.deleteAccount(); + + /// Whether this instance offers signing in with GitHub. + Future canSignInWithGitHub() async => + await _gateway.gitHubClientId() != null; + + /// Links a GitHub account to the signed-in person; the person, and all + /// their data, stay the same. + Future linkGitHub() => _gateway.linkGitHub(); + + /// Switches this device to the person who has the GitHub account. The + /// anonymous account this device leaves behind can't be signed in again. + Future switchToGitHub() => _gateway.switchToGitHub(); + + /// Whether the app offers to cancel a GitHub sign-in in progress, where no + /// sign-in screen of the system does. + bool get canCancelGitHub => _gateway.canCancelGitHub; + + /// Cancels the GitHub sign-in in progress while GitHub's page is open. + Future cancelGitHub() => _gateway.cancelGitHub(); + + /// Whether this instance offers signing in with an email address. + Future canSignInWithEmail() => _gateway.offersEmailSignIn(); + + /// Mails a code to [address], worded in [language]; returns the request + /// it answers. Throws `EmailSignInFailed`. + Future sendEmailCode(String address, {required String language}) => + _gateway.sendEmailCode(address, language: language); + + /// Accepts [code] for [request], mailed to [address]: the address becomes + /// the signed-in person's, who keeps everything (and this device + /// remembers it, to show), unless it is another person's. Throws + /// `EmailSignInFailed`. + Future verifyEmailCode( + String request, + String code, { + required String address, + }) async { + final person = _gateway.status; + final outcome = await _gateway.verifyEmailCode(request, code); + if ((outcome, person) case ( + EmailLinked(), + SignedIn(:final authUserId), + )) { + await _addresses.remember(authUserId, address); + } + return outcome; + } + + /// Switches this device to the person who signs in with [address], whom + /// [switchToken] names. An anonymous account left behind can't be signed + /// into again, and goes. Throws `EmailSignInFailed`. + Future switchToEmail( + String switchToken, { + required String address, + }) async { + final person = await _gateway.switchToEmail(switchToken); + // Into the folder of the person switched to, which stays. + await _addresses.remember(person, address); + } + + /// How the signed-in person signs in. + Future signInWays() => _gateway.signInWays(); + + /// The email address the signed-in person signed in with on this device; + /// null when they didn't, or did elsewhere. + Future emailAddress() async => switch (_gateway.status) { + SignedIn(:final authUserId) => await _addresses.of(authUserId), + SignedOut() => null, + }; +} diff --git a/packages/halkyon_app/lib/src/auth/data/loopback_web_auth.dart b/packages/halkyon_app/lib/src/auth/data/loopback_web_auth.dart new file mode 100644 index 0000000..6a5663c --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/loopback_web_auth.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:window_to_front/window_to_front.dart'; + +/// The browser half of signing in with GitHub on Linux and Windows: opens +/// GitHub's page in the system browser and waits on a loopback address until +/// the browser comes back (RFC 8252, section 7.3). +/// +/// It takes the place of flutter_web_auth_2's own implementation, which the +/// package's front ends whenever the app is resumed. That suits mobile, where +/// coming back to the app without finishing means giving up, but on the +/// desktop the app's window regaining focus resumes it, and the browser came +/// back to a closed port. Remove this class once the package no longer ends +/// the sign-in on Linux and Windows then. +/// +/// The wait ends when the browser comes back, on [cancel], when another +/// sign-in starts, or after [_timeout]. +class LoopbackWebAuth({ + /// The app's name, as the browser's page says to go back to it. + final String appName = 'Halkyon', + + final Future Function(Uri url) _launch = launchUrl, + final Future Function() _bringToFront = WindowToFront.activate, + final Duration _timeout = const Duration(minutes: 5), +}) extends FlutterWebAuth2Platform { + HttpServer? _server; + Completer? _pending; + + /// Opens [url] in the browser and returns the URL the browser comes back + /// to. [callbackUrlScheme] is the whole redirect URI, on 127.0.0.1 with a + /// port; only its path ends the wait. [options] are the package's and + /// don't apply. + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + final callback = Uri.parse(callbackUrlScheme); + if (callback.scheme != 'http' || + callback.host != InternetAddress.loopbackIPv4.address || + !callback.hasPort || + callback.path.isEmpty) { + throw ArgumentError.value( + callbackUrlScheme, + 'callbackUrlScheme', + 'must be a path on http://127.0.0.1 with a port', + ); + } + await cancel(); + // The loopback address only: nothing but this device can answer. + final server = _server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + callback.port, + ); + final pending = _pending = Completer()..future.ignore(); + server.listen((request) => _answer(request, callback.path, pending)); + try { + if (!await _launch(Uri.parse(url))) { + throw PlatformException( + code: 'FAILED', + message: "Couldn't open the browser.", + ); + } + final result = await pending.future.timeout(_timeout); + try { + await _bringToFront(); + } on Exception catch (error, st) { + // The sign-in worked; the person switches back by themselves. + log( + "Couldn't bring the window to the front", + name: 'Auth', + error: error, + stackTrace: st, + ); + } + return result; + } finally { + await server.close(); + if (identical(_server, server)) { + _server = null; + _pending = null; + } + } + } + + /// Does nothing: the package's front calls it whenever the app is resumed, + /// which on the desktop includes the window regaining focus. [cancel] ends + /// a sign-in instead. + @override + Future clearAllDanglingCalls() async {} + + /// Stops waiting: the sign-in in progress ends as cancelled, the way the + /// package reports it. Does nothing when none is in progress. + Future cancel() async { + final pending = _pending; + final server = _server; + _pending = null; + _server = null; + if (pending != null && !pending.isCompleted) { + pending.completeError( + PlatformException(code: 'CANCELED', message: 'User canceled login'), + ); + } + await server?.close(force: true); + } + + /// Answers the browser: the first request for the callback's path ends the + /// wait, anything else is not found. + Future _answer( + HttpRequest request, + String callbackPath, + Completer pending, + ) async { + final response = request.response; + if (request.uri.path == callbackPath && !pending.isCompleted) { + pending.complete(request.requestedUri.toString()); + response + ..headers.contentType = ContentType.html + ..write(_landingPage(appName)); + } else { + response.statusCode = HttpStatus.notFound; + } + try { + await response.close(); + } on IOException { + // The browser went away before the answer; nothing is left to tell it. + } + } +} + +/// What the browser shows once it has come back to [app]. +String _landingPage(String app) { + final name = const HtmlEscape().convert(app); + return ''' + + + +$name +

You can close this tab and return to $name.

+ +'''; +} diff --git a/packages/halkyon_app/lib/src/auth/data/serverpod_auth_gateway.dart b/packages/halkyon_app/lib/src/auth/data/serverpod_auth_gateway.dart new file mode 100644 index 0000000..9ccad4e --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/serverpod_auth_gateway.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; +import 'package:halkyon_app/src/auth/data/loopback_web_auth.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; +import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; +import 'package:halkyon_app/src/auth/model/github_sign_in.dart'; +import 'package:halkyon_app/src/auth/model/sign_in_ways.dart'; +import 'package:halkyon_app/src/base/base_client.dart'; +import 'package:halkyon_base_client/halkyon_base_client.dart' + show + EmailSignInRefusal, + EmailSignInRefusedException, + GitHubSignInRefusal, + GitHubSignInRefusedException; +import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client; +import 'package:http/http.dart' as http; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +/// [AuthGateway] on top of an app's Serverpod client and its session +/// manager, which keeps the session on the device (secure storage). Signing +/// in and the account are the instance's halkyon_base's, called through +/// [_base], the module's endpoints on that client +/// (`client.modules.base`). +class ServerpodAuthGateway( + final ServerpodClientShared _client, { + required final base_client.Caller _base, + + /// The app's own URL scheme, which GitHub sends the browser back to on + /// Android, iOS and macOS (`app.halkyon.learn`), registered with the + /// system for the app and in the instance's GitHub OAuth app. + required final String callbackScheme, + + /// The port on 127.0.0.1 the app listens on for GitHub's answer on Linux + /// and Windows. + final int loopbackPort = 43823, + + /// The app's name, as the browser's page says to go back to it. + required final String appName, + + /// What the clients it makes for itself send through (to sign in as + /// another person, and to delete an account left behind); tests give a + /// fake one. + final http.Client Function()? _httpClient, +}) implements AuthGateway { + this { + _client.auth.authInfoListenable.addListener(_onAuthInfoChanged); + } + + final _changes = StreamController.broadcast(); + + /// The scope a person gets once they have an identity. + static const _identifiedScope = 'identified'; + + /// Where GitHub sends the browser back after authorizing: the app's own + /// scheme on Android, iOS and macOS; on Linux and Windows a loopback address + /// the app listens on meanwhile (GitHub accepts any port there, so the + /// OAuth app registers `http://127.0.0.1/github-callback`). There, + /// [LoopbackWebAuth] takes the whole address, to answer only its path. + late final _gitHubCallback = Platform.isLinux || Platform.isWindows + ? ( + redirectUri: 'http://127.0.0.1:$loopbackPort/github-callback', + urlScheme: 'http://127.0.0.1:$loopbackPort/github-callback', + ) + : ( + redirectUri: '$callbackScheme://github-callback', + urlScheme: callbackScheme, + ); + + /// The browser half of signing in with GitHub on Linux and Windows, in + /// place of flutter_web_auth_2's own, which stops waiting whenever the + /// window regains focus. Elsewhere, the package's. + late final _loopback = Platform.isLinux || Platform.isWindows + ? LoopbackWebAuth(appName: appName) + : null; + + /// Whether the person cancelled the GitHub sign-in in progress. + var _gitHubCancelled = false; + + @override + AuthStatus get status => switch (_client.auth.authInfo) { + null => const SignedOut(), + final authInfo => SignedIn( + authUserId: authInfo.authUserId.toString(), + isIdentified: authInfo.scopeNames.contains(_identifiedScope), + ), + }; + + @override + Stream get statusChanges => _changes.stream; + + @override + Future signInAnonymously() async { + final authSuccess = await _base.anonymousIdp.login(); + await _client.auth.updateSignedInUser(authSuccess); + } + + @override + Future signOut() async { + await _client.auth.signOutDevice(); + } + + @override + Future deleteAccount() async { + await _base.account.delete(); + // The server ended every session of the person; forget this device's + // copy. + await _client.auth.updateSignedInUser(null); + } + + @override + Future gitHubClientId() async => + (await _base.instance.basics()).gitHubClientId; + + @override + Future linkGitHub() async { + final authorization = await _authorizeWithGitHub(); + if (authorization == null) { + return GitHubLinkOutcome.cancelled; + } + try { + await _base.gitHubUpgrade.upgrade( + code: authorization.code, + codeVerifier: authorization.codeVerifier, + redirectUri: authorization.redirectUri, + ); + } on GitHubSignInRefusedException catch (error) { + if (error.reason != GitHubSignInRefusal.takenByAnotherPerson) { + throw _gitHubFailure(error); + } + return GitHubLinkOutcome.takenByAnotherPerson; + } + await _becomeIdentified(); + return GitHubLinkOutcome.linked; + } + + /// The server gave the person's sessions the identified scope; the copy of + /// this device's session follows. + Future _becomeIdentified() async { + if (_client.auth.authInfo case final authInfo?) { + await _client.auth.updateSignedInUser( + authInfo.copyWith( + scopeNames: {...authInfo.scopeNames, _identifiedScope}, + ), + ); + } + } + + @override + Future switchToGitHub() async { + final authorization = await _authorizeWithGitHub(); + if (authorization == null) { + return false; + } + // Serverpod issues no session for another person inside a signed-in + // call, so a client without a session asks for it. + final signedOut = _clientOfItsOwn(); + final AuthSuccess person; + try { + person = await signedOut.modules.gitHubSignIn.signIn( + code: authorization.code, + codeVerifier: authorization.codeVerifier, + redirectUri: authorization.redirectUri, + ); + } on GitHubSignInRefusedException catch (error) { + throw _gitHubFailure(error); + } finally { + signedOut.close(); + } + await _switchTo(person); + return true; + } + + /// Switches this device to [person]. Swapping directly means the app never + /// passes through "signed out", which would start yet another anonymous + /// account. An anonymous account left behind is deleted, as nobody could + /// sign into it again; an identified one stays, to be signed into again. + Future _switchTo(AuthSuccess person) async { + final leftBehind = _client.auth.authInfo; + await _client.auth.updateSignedInUser(person); + if (leftBehind != null && + !leftBehind.scopeNames.contains(_identifiedScope)) { + await _deleteLeftBehind(leftBehind); + } + } + + @override + bool get canCancelGitHub => _loopback != null; + + @override + Future cancelGitHub() async { + _gitHubCancelled = true; + await _loopback?.cancel(); + } + + /// GitHub's authorization in the browser, or null when the person + /// cancelled it. + Future _authorizeWithGitHub() async { + _gitHubCancelled = false; + final clientId = await gitHubClientId(); + if (clientId == null) { + throw StateError('This instance offers no GitHub sign-in.'); + } + await GitHubSignInService.instance.ensureInitialized( + clientId: clientId, + redirectUri: _gitHubCallback.redirectUri, + callbackUrlScheme: _gitHubCallback.urlScheme, + // On Linux and Windows the system browser and a loopback address, not + // an embedded webview, as the OAuth best practice for native apps + // (RFC 8252) asks. + useWebview: false, + ); + if (_loopback case final loopback?) { + // Here, after the package has put its own in place at startup. + FlutterWebAuth2Platform.instance = loopback; + } + if (_gitHubCancelled) { + return null; + } + try { + return await GitHubSignInService.instance.signIn(); + } on OAuth2PkceUserCancelledException { + return null; + } on OAuth2PkceException { + // Serverpod (checked with v4.0.3) reports a cancelled browser round + // trip as an unknown error, so a cancel is known by having asked for + // it. + if (_gitHubCancelled) { + return null; + } + rethrow; + } + } + + @override + Future offersEmailSignIn() async => + (await _base.instance.basics()).emailSignIn; + + @override + Future sendEmailCode( + String address, { + required String language, + }) => _asEmailFailure(() async { + final request = await _base.emailCode.sendCode( + address: address, + language: language, + ); + return request.uuid; + }); + + @override + Future verifyEmailCode(String request, String code) => + _asEmailFailure(() async { + final accepted = await _base.emailCode.verifyCode( + request: UuidValue.withValidation(request), + code: code, + ); + if (accepted.switchToken case final token?) { + return EmailTakenByAnotherPerson(token); + } + await _becomeIdentified(); + return const EmailLinked(); + }); + + @override + Future switchToEmail(String switchToken) => _asEmailFailure(() async { + // As switchToGitHub: without a session. + final signedOut = _clientOfItsOwn(); + final AuthSuccess person; + try { + person = await signedOut.modules.emailSignIn.signIn( + switchToken: switchToken, + ); + } finally { + signedOut.close(); + } + await _switchTo(person); + return person.authUserId.uuid; + }); + + @override + Future signInWays() async { + final ways = await _base.account.signInWays(); + return SignInWays(gitHub: ways.gitHub, email: ways.email); + } + + /// The instance's refusal of a GitHub sign-in as [GitHubSignInFailed]. + static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) => + GitHubSignInFailed( + e.reason == GitHubSignInRefusal.unknownAccount + ? GitHubSignInProblem.unknownAccount + : GitHubSignInProblem.refused, + ); + + /// What [action] gives; the instance's refusals as [EmailSignInFailed]. + Future _asEmailFailure(Future Function() action) async { + try { + return await action(); + } on EmailSignInRefusedException catch (refusal) { + throw EmailSignInFailed(switch (refusal.reason) { + EmailSignInRefusal.unavailable => EmailSignInProblem.unavailable, + EmailSignInRefusal.notAnAddress => EmailSignInProblem.notAnAddress, + EmailSignInRefusal.tooManyCodes => EmailSignInProblem.tooManyCodes, + EmailSignInRefusal.wrongCode => EmailSignInProblem.wrongCode, + EmailSignInRefusal.tooManyTries => EmailSignInProblem.tooManyTries, + EmailSignInRefusal.codeExpired => EmailSignInProblem.codeExpired, + EmailSignInRefusal.otherAddressLinked => + EmailSignInProblem.otherAddressLinked, + EmailSignInRefusal.mailFailed => EmailSignInProblem.mailFailed, + }); + } + } + + /// Deletes the anonymous account this device leaves behind when switching: + /// nobody could ever sign into it again. It signs in as that account from + /// a client of its own; a failure only leaves the account to the pruning. + Future _deleteLeftBehind(AuthSuccess anonymous) async { + final sessionManager = FlutterAuthSessionManager( + storage: _MemoryAuthStorage(), + ); + final client = _clientOfItsOwn()..authSessionManager = sessionManager; + try { + await sessionManager.updateSignedInUser(anonymous); + await client.modules.account.delete(); + } on Exception catch (error, st) { + log( + 'Deleting the anonymous account left behind failed', + name: 'Auth', + error: error, + stackTrace: st, + ); + } finally { + client.close(); + } + } + + /// A client of the instance's halkyon_base, holding no session. + BaseClient _clientOfItsOwn() => + BaseClient(_client.host, httpClientOverride: _httpClient?.call()); + + /// Stops listening; the owner calls it when the app shuts down. + Future dispose() async { + _client.auth.authInfoListenable.removeListener(_onAuthInfoChanged); + await _changes.close(); + } + + void _onAuthInfoChanged() => _changes.add(status); +} + +/// Keeps one session in memory, for a client used once. +final class _MemoryAuthStorage() implements ClientAuthSuccessStorage { + AuthSuccess? _session; + + @override + Future get() async => _session; + + @override + Future set(AuthSuccess? data) async { + _session = data; + } +} diff --git a/packages/halkyon_app/lib/src/auth/data/sign_in_addresses.dart b/packages/halkyon_app/lib/src/auth/data/sign_in_addresses.dart new file mode 100644 index 0000000..e30f96b --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/sign_in_addresses.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// The email address each person signs in with, as this device knows it: +/// the instance keeps only a keyed hash of it, so only the device that +/// typed it can show it. Tests keep it in memory. +abstract interface class SignInAddresses() { + /// The address [authUserId] signed in with on this device; null when they + /// didn't, or did elsewhere. + Future of(String authUserId); + + /// Remembers that [authUserId] signs in with [address]. + Future remember(String authUserId, String address); +} + +/// [SignInAddresses] in each person's folder under [_people] +/// (`//sign_in.json`), so it goes with the rest of +/// their data when another person's opens (`PersonFolders`). +class FileSignInAddresses(final Directory _people) implements SignInAddresses { + @override + Future of(String authUserId) async { + try { + return switch (jsonDecode(await _fileOf(authUserId).readAsString())) { + {'email': final String address} => address, + _ => null, + }; + } on IOException { + return null; + } on FormatException catch (error) { + log('Unreadable sign_in.json', name: 'Auth', error: error); + return null; + } + } + + @override + Future remember(String authUserId, String address) async { + final file = _fileOf(authUserId); + await file.parent.create(recursive: true); + await file.writeAsString(jsonEncode({'email': address})); + } + + File _fileOf(String authUserId) => + File(p.join(_people.path, authUserId, 'sign_in.json')); +} diff --git a/packages/halkyon_app/lib/src/auth/data/signed_out_mark.dart b/packages/halkyon_app/lib/src/auth/data/signed_out_mark.dart new file mode 100644 index 0000000..a617050 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/data/signed_out_mark.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Whether the person on this device signed out of the instance, and +/// nobody started since: the app then asks how to start rather than making +/// an anonymous account by itself, at once and at every start after. +abstract interface class SignedOutMark() { + /// Whether the device is marked signed out. + Future read(); + + /// Marks the device signed out, or not. + Future write({required bool signedOut}); +} + +/// The mark as a file in the folder of the instance's people, which +/// outlives every person's own. +class FileSignedOutMark(final Directory _people) implements SignedOutMark { + File get _file => File(p.join(_people.path, 'signed_out')); + + @override + Future read() async => _file.existsSync(); + + @override + Future write({required bool signedOut}) async { + if (signedOut) { + await _people.create(recursive: true); + await _file.writeAsString(''); + } else if (_file.existsSync()) { + await _file.delete(); + } + } +} diff --git a/packages/halkyon_app/lib/src/auth/model/auth_status.dart b/packages/halkyon_app/lib/src/auth/model/auth_status.dart new file mode 100644 index 0000000..b2a0e5f --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/model/auth_status.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +/// Who is signed in on this device. +sealed class const AuthStatus() with Equatable; + +/// Nobody is signed in. +final class const SignedOut() extends AuthStatus { + @override + List get props => const []; +} + +/// A person is signed in. +final class const SignedIn({ + /// The person, i.e. the server's `AuthUser` id. + required final String authUserId, + + /// Whether the person has an identity, such as GitHub. Anonymous people have + /// none: their progress lives on this device, and signing out loses it. + required final bool isIdentified, +}) extends AuthStatus { + @override + List get props => [authUserId, isIdentified]; +} diff --git a/packages/halkyon_app/lib/src/auth/model/email_sign_in.dart b/packages/halkyon_app/lib/src/auth/model/email_sign_in.dart new file mode 100644 index 0000000..43bcf4e --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/model/email_sign_in.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; + +/// Why signing in with an email address didn't go through, as the widgets +/// put it into words. +enum EmailSignInProblem() { + /// This instance offers no email sign-in. + unavailable, + + /// What was typed isn't an email address. + notAnAddress, + + /// Too many codes asked for in the last hour. + tooManyCodes, + + /// The code typed is wrong. + wrongCode, + + /// The code was typed wrong too often; a new one is needed. + tooManyTries, + + /// The code is no longer valid: past its 15 minutes, used, or replaced. + codeExpired, + + /// The person already signs in with another address. + otherAddressLinked, + + /// The instance couldn't send the mail. + mailFailed, + + /// The instance couldn't be reached, or answered otherwise. + failed, +} + +/// Signing in with an email address didn't go through, for [problem]. +final class const EmailSignInFailed(final EmailSignInProblem problem) + implements Exception { + @override + String toString() => 'EmailSignInFailed(${problem.name})'; +} + +/// How accepting a mailed code ended. +sealed class const EmailCodeOutcome() with Equatable { + @override + List get props => const []; +} + +/// The address is now the signed-in person's (or already was): they are +/// identified, keeping everything. +final class const EmailLinked() extends EmailCodeOutcome; + +/// The address is another person's: [switchToken] switches this device to +/// them, once, within the code's 15 minutes. +final class const EmailTakenByAnotherPerson(final String switchToken) + extends EmailCodeOutcome { + @override + List get props => [switchToken]; +} diff --git a/packages/halkyon_app/lib/src/auth/model/github_link_outcome.dart b/packages/halkyon_app/lib/src/auth/model/github_link_outcome.dart new file mode 100644 index 0000000..42fac69 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/model/github_link_outcome.dart @@ -0,0 +1,11 @@ +/// How linking a GitHub account to the signed-in person ended. +enum GitHubLinkOutcome() { + /// Linked: the person now signs in with GitHub and counts as identified. + linked, + + /// The GitHub account belongs to another person; nothing changed. + takenByAnotherPerson, + + /// The person closed GitHub's page without authorizing; nothing changed. + cancelled, +} diff --git a/packages/halkyon_app/lib/src/auth/model/github_sign_in.dart b/packages/halkyon_app/lib/src/auth/model/github_sign_in.dart new file mode 100644 index 0000000..8e2669b --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/model/github_sign_in.dart @@ -0,0 +1,17 @@ +/// Why signing in with GitHub didn't go through, beyond a cancel. +enum GitHubSignInProblem() { + /// No person has this GitHub account yet; only adding it to one makes it + /// a way in. + unknownAccount, + + /// The instance refused it otherwise: it offers no GitHub sign-in, or the + /// person already has a different GitHub account. + refused, +} + +/// Signing in with GitHub didn't go through, for [problem]. +final class const GitHubSignInFailed(final GitHubSignInProblem problem) + implements Exception { + @override + String toString() => 'GitHubSignInFailed(${problem.name})'; +} diff --git a/packages/halkyon_app/lib/src/auth/model/sign_in_ways.dart b/packages/halkyon_app/lib/src/auth/model/sign_in_ways.dart new file mode 100644 index 0000000..47d5534 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/model/sign_in_ways.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +/// How a person signs in, besides the sessions of their devices: with +/// GitHub, with an email address, both, or neither (anonymous). +final class const SignInWays({ + required final bool gitHub, + required final bool email, +}) with Equatable { + /// An anonymous person's: neither. + static const none = SignInWays(gitHub: false, email: false); + + @override + List get props => [gitHub, email]; +} diff --git a/packages/halkyon_app/lib/src/auth/widget/email_sign_in_sheet.dart b/packages/halkyon_app/lib/src/auth/widget/email_sign_in_sheet.dart new file mode 100644 index 0000000..072a1c6 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/widget/email_sign_in_sheet.dart @@ -0,0 +1,292 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/auth/bloc/email_sign_in_bloc.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Signs in with an email address in a sheet: the address, then the code +/// mailed to it. Returns the address once it is the person's; null when the +/// sheet closed otherwise, or the device switched to the address's person, +/// which starts the app afresh at home. [deviceHoldsContent] says whether a +/// switch would leave content of this device's behind, so the person is +/// asked first ([AppFrame.switchLeavesBehind] says what). +Future showEmailSignInSheet( + BuildContext context, { + required Future Function() deviceHoldsContent, +}) { + final auth = AppScope.of(context).auth; + final language = switch (Localizations.localeOf(context).languageCode) { + 'de' => 'de', + _ => 'en', + }; + return showAdaptiveSheet( + context, + // Swiping away would cut off a code being checked, or a switch. + swipeable: false, + builder: (context) => BlocProvider( + create: (context) => EmailSignInBloc( + auth, + deviceHoldsContent: deviceHoldsContent, + language: language, + ), + child: const _EmailSignIn(), + ), + ); +} + +/// [problem] in [l10n]'s words. +String emailSignInProblemText( + HalkyonAppLocalizations l10n, + EmailSignInProblem problem, +) => switch (problem) { + EmailSignInProblem.unavailable => l10n.emailSignInUnavailable, + EmailSignInProblem.notAnAddress => l10n.emailSignInNotAnAddress, + EmailSignInProblem.tooManyCodes => l10n.emailSignInTooManyCodes, + EmailSignInProblem.wrongCode => l10n.emailSignInWrongCode, + EmailSignInProblem.tooManyTries => l10n.emailSignInTooManyTries, + EmailSignInProblem.codeExpired => l10n.emailSignInCodeExpired, + EmailSignInProblem.otherAddressLinked => l10n.emailSignInOtherAddressLinked, + EmailSignInProblem.mailFailed => l10n.emailSignInMailFailed, + EmailSignInProblem.failed => l10n.emailSignInFailed, +}; + +class const _EmailSignIn() extends StatefulWidget { + @override + State<_EmailSignIn> createState() => _EmailSignInState(); +} + +class _EmailSignInState() extends State<_EmailSignIn> { + final _address = TextEditingController(); + final _code = TextEditingController(); + + @override + void dispose() { + _address.dispose(); + _code.dispose(); + super.dispose(); + } + + void _send() => context.read().add( + EmailSignInEventCodeRequested(_address.text), + ); + + void _check() => context.read().add( + EmailSignInEventCodeEntered(_code.text), + ); + + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + return BlocConsumer( + listener: (context, state) { + switch (state) { + case EmailSignInStateLinked(:final address): + Navigator.of(context).pop(address); + // A new code: the field waits for it. + case EmailSignInStateCode(problem: null, checking: false): + _code.clear(); + case _: + } + }, + builder: (context, state) { + final busy = switch (state) { + EmailSignInStateAddress(:final sending) => sending, + EmailSignInStateCode(:final checking) => checking, + EmailSignInStateSwitching() || EmailSignInStateLinked() => true, + _ => false, + }; + return SheetScaffold( + title: l10n.emailSignInTitle, + busy: busy, + body: switch (state) { + EmailSignInStateAddress(:final problem) => _addressStep( + context, + problem: problem, + busy: busy, + ), + final EmailSignInStateCode code => _codeStep( + context, + code, + busy: busy, + ), + EmailSignInStateConfirmSwitch(:final address) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.emailSignInSwitchTitle(address), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(AppFrameScope.of(context).switchLeavesBehind(context)), + ], + ), + EmailSignInStateTaken(:final address) => Text( + l10n.emailSignInTaken(address), + ), + EmailSignInStateSwitching() || + EmailSignInStateLinked() => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: AppLoadingIndicator()), + ), + }, + footer: switch (state) { + EmailSignInStateAddress() => FilledButton( + onPressed: busy ? null : _send, + child: busy + ? const ButtonLoadingIndicator() + : Text(l10n.emailSignInSend), + ), + EmailSignInStateCode() => FilledButton( + onPressed: busy ? null : _check, + child: busy + ? const ButtonLoadingIndicator() + : Text(l10n.emailSignInContinue), + ), + EmailSignInStateConfirmSwitch() => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => context.read().add( + const EmailSignInEventSwitchDeclined(), + ), + child: Text(l10n.emailSignInSwitchStay), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => context.read().add( + const EmailSignInEventSwitchConfirmed(), + ), + child: Text(l10n.emailSignInSwitchConfirm), + ), + ], + ), + EmailSignInStateTaken() => OutlinedButton( + onPressed: () => context.read().add( + const EmailSignInEventAddressReopened(), + ), + child: Text(l10n.emailSignInOtherAddress), + ), + EmailSignInStateSwitching() || EmailSignInStateLinked() => null, + }, + ); + }, + ); + } + + Widget _addressStep( + BuildContext context, { + required EmailSignInProblem? problem, + required bool busy, + }) { + final l10n = HalkyonAppLocalizations.of(context); + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.emailSignInIntro, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: _address, + builder: (context, _) => TextField( + controller: _address, + enabled: !busy, + autofocus: true, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + maxLength: TextLimits.emailAddress, + buildCounter: nearLimitCounter, + decoration: requiredDecoration( + context, + InputDecoration( + labelText: l10n.emailSignInAddress, + errorText: problem == null + ? null + : emailSignInProblemText(l10n, problem), + ), + missing: _address.text.trim().isEmpty, + ), + ), + ), + ], + ); + } + + Widget _codeStep( + BuildContext context, + EmailSignInStateCode code, { + required bool busy, + }) { + final l10n = HalkyonAppLocalizations.of(context); + final bloc = context.read(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.emailSignInCodeSent(code.address)), + if (code.resent) ...[ + const SizedBox(height: 4), + Text( + l10n.emailSignInResent, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: 16), + TextField( + controller: _code, + enabled: !busy, + autofocus: true, + autofillHints: const [AutofillHints.oneTimeCode], + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.characters, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _check(), + // Eight characters, a dash or space perhaps between them. + maxLength: 9, + buildCounter: nearLimitCounter, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z\- ]')), + ], + decoration: InputDecoration( + labelText: l10n.emailSignInCode, + hintText: 'K7QX-M2PD', + errorText: switch (code.problem) { + final problem? => emailSignInProblemText(l10n, problem), + null => null, + }, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: busy + ? null + : () => bloc.add(const EmailSignInEventCodeResent()), + child: Text(l10n.emailSignInResend), + ), + TextButton( + onPressed: busy + ? null + : () => bloc.add(const EmailSignInEventAddressReopened()), + child: Text(l10n.emailSignInOtherAddress), + ), + ], + ), + ], + ); + } +} diff --git a/packages/halkyon_app/lib/src/auth/widget/session_gate.dart b/packages/halkyon_app/lib/src/auth/widget/session_gate.dart new file mode 100644 index 0000000..dd5f4cb --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/widget/session_gate.dart @@ -0,0 +1,80 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/auth/bloc/session_bloc.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/widget/start_screen.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Shows the app of whoever is signed in, signing in anonymously at a +/// first start, and asking how to start once signed out ([StartScreen]). +/// +/// [app] is built anew for every person (keyed by the person), so nothing of +/// one person's app, such as screens, blocs, streams or caches, reaches +/// another's. Until someone is signed in, a plain app shows the progress, or +/// the failure with a way to try again, in the look [look] reads. +/// +/// What outlives every person, such as the device's own settings, goes +/// above it. +class const SessionGate({ + /// The app of a signed-in person. + required final Widget Function(SignedIn person) app, + + /// The device's theme mode and language, for the look while nobody is + /// signed in; read where it can follow them (`context.watch`). + required final LookSettings Function(BuildContext context) look, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => BlocProvider( + create: (context) => SessionBloc( + AppScope.of(context).auth, + AppScope.of(context).signedOut, + )..add(const SessionEventStarted()), + child: BlocBuilder( + builder: (context, state) => switch (state) { + SessionStateLoading() => PlainApp( + look: look(context), + home: AppFrameScope.of(context).launchScreen, + ), + SessionStateFailed() => PlainApp( + look: look(context), + home: const _SignInFailed(), + ), + final SessionStateSignedOut signedOut => PlainApp( + look: look(context), + home: StartScreen(state: signedOut), + ), + SessionStateActive(:final person) => KeyedSubtree( + key: ValueKey(person.authUserId), + child: app(person), + ), + }, + ), + ); +} + +class const _SignInFailed() extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.sessionUnreachable), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.read().add( + const SessionEventSignInRequested(), + ), + child: Text(l10n.tryAgain), + ), + ], + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/auth/widget/start_screen.dart b/packages/halkyon_app/lib/src/auth/widget/start_screen.dart new file mode 100644 index 0000000..e6dbf79 --- /dev/null +++ b/packages/halkyon_app/lib/src/auth/widget/start_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/auth/bloc/session_bloc.dart'; +import 'package:halkyon_app/src/instance/instance_scope.dart'; +import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// How to start once signed out: on the instance in use, with GitHub or an +/// email address where it offers them, or without an account; or on +/// another instance. Nothing is made until the person chooses. +class const StartScreen({required final SessionStateSignedOut state, super.key}) + extends StatefulWidget { + @override + State createState() => _StartScreenState(); +} + +class _StartScreenState() extends State { + late final Future<({bool gitHub, bool email})> _offers = () async { + final auth = AppScope.of(context).auth; + final (gitHub, email) = await ( + auth.canSignInWithGitHub(), + auth.canSignInWithEmail(), + ).wait; + return (gitHub: gitHub, email: email); + }(); + + void _send(SessionEvent event) => context.read().add(event); + + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + final theme = Theme.of(context); + final instance = + InstanceScope.maybeOf(context)?.choice.current?.name ?? + AppScope.of(context).serverName; + final busy = widget.state.busy; + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: FutureBuilder( + future: _offers, + builder: (context, offers) { + final (:gitHub, :email) = + offers.data ?? (gitHub: false, email: false); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.startSignedOut, + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.startOn(instance), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (gitHub) ...[ + FilledButton( + onPressed: busy + ? null + : () => _send( + const SessionEventGitHubRequested(), + ), + child: busy + ? const ButtonLoadingIndicator() + : Text(l10n.startWithGitHub), + ), + const SizedBox(height: 8), + ], + if (email) ...[ + OutlinedButton( + onPressed: busy + ? null + : () => _send(const SessionEventEmailRequested()), + child: Text(l10n.startWithEmail), + ), + const SizedBox(height: 8), + ], + FilledButton.tonal( + onPressed: busy + ? null + : () => _send( + const SessionEventStartedAnonymously(), + ), + child: Text(l10n.startWithoutAccount), + ), + const SizedBox(height: 8), + Text( + l10n.startAnonymous, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (widget.state.problem case final problem?) ...[ + const SizedBox(height: 16), + Text( + switch (problem) { + SignedOutProblem.noGitHubAccount => + l10n.startNoGitHubAccount, + SignedOutProblem.failed => l10n.startGitHubFailed, + }, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + if (InstanceScope.maybeOf(context) != null) ...[ + const SizedBox(height: 24), + TextButton( + onPressed: busy + ? null + : () => showAccountSwitcher(context), + child: Text(l10n.startOtherInstance), + ), + ], + ], + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/base/base_client.dart b/packages/halkyon_app/lib/src/base/base_client.dart new file mode 100644 index 0000000..917956e --- /dev/null +++ b/packages/halkyon_app/lib/src/base/base_client.dart @@ -0,0 +1,74 @@ +import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client; +import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart' + as core; +import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart' + as idp; +import 'package:serverpod_client/serverpod_client.dart'; + +/// A client of an instance's halkyon_base alone, apart from an app's own +/// client: to ask an instance about itself before the app moves there, and +/// to sign in as another person than the one the app's client holds (it +/// holds no session unless given one, `authSessionManager`, which finds +/// Serverpod's auth module among [moduleLookup]). +class BaseClient(String host, {super.httpClientOverride}) + extends ServerpodClientShared { + this + : super( + host, + BaseProtocol(), + streamingConnectionTimeout: null, + connectionTimeout: null, + ); + + /// halkyon_base's endpoints. + late final modules = base_client.Caller(this); + + /// Serverpod's auth module, which a session manager given to this client + /// signs in and out through. + late final _authCore = core.Caller(this); + + @override + Map get endpointRefLookup => const {}; + + @override + Map get moduleLookup => { + 'base': modules, + 'serverpod_auth_core': _authCore, + }; +} + +/// How a [BaseClient] reads what the instance sends: halkyon_base's models, +/// and those of the auth modules beneath it. The instance names each by its +/// module (`halkyon_base.EmailSignInRefusedException`), as an app's own +/// generated protocol reads them. +class BaseProtocol() extends SerializationManager { + static const _modules = { + 'halkyon_base.': base_client.Protocol.new, + 'serverpod_auth_idp.': idp.Protocol.new, + 'serverpod_auth_core.': core.Protocol.new, + }; + + final _base = base_client.Protocol(); + + @override + T deserialize(dynamic data, [Type? t]) => _base.deserialize(data, t); + + @override + String? getClassNameForObject(Object? data) => + _base.getClassNameForObject(data); + + @override + dynamic deserializeByClassName(Map data) { + if (data['className'] case final String name) { + for (final MapEntry(key: prefix, value: protocol) in _modules.entries) { + if (name.startsWith(prefix)) { + return protocol().deserializeByClassName({ + ...data, + 'className': name.substring(prefix.length), + }); + } + } + } + return _base.deserializeByClassName(data); + } +} diff --git a/packages/halkyon_app/lib/src/instance/country_names.dart b/packages/halkyon_app/lib/src/instance/country_names.dart new file mode 100644 index 0000000..ecb9858 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/country_names.dart @@ -0,0 +1,28 @@ +/// Countries' names by their code (ISO 3166-1 alpha-2), in a language, as +/// the app knows them (Halkyon Learn: its world map's, Unicode CLDR's). +/// Tests give them with [MemoryCountryNames]. +abstract interface class CountryNames() { + /// [code]'s name in [language], else in English; null for a code without + /// one. + Future nameOf(String code, String language); +} + +/// The names given, by code and then language, as a test gives them. +class MemoryCountryNames(final Map> _names) + implements CountryNames { + @override + Future nameOf(String code, String language) async => + countryNameIn(_names, code, language); +} + +/// [code]'s name in [language] among [names] (by code, then language), else +/// in English; null for a code without one. Codes are matched whatever +/// their case. +String? countryNameIn( + Map> names, + String code, + String language, +) { + final of = names[code.toUpperCase()]; + return of?[language] ?? of?['en']; +} diff --git a/packages/halkyon_app/lib/src/instance/flag_icons_license.dart b/packages/halkyon_app/lib/src/instance/flag_icons_license.dart new file mode 100644 index 0000000..60ecdd4 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/flag_icons_license.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Adds the flags' notice to the app's licence list, beside the packages' +/// own: the flag package draws lipis' flag-icons (v7.5.0), whose MIT +/// licence asks that its notice go with every copy, and the package's own +/// licence names only the package. An app calls it once at startup. +void registerFlagIconsLicense() => LicenseRegistry.addLicense(() async* { + yield LicenseEntryWithLineBreaks( + const ['flag-icons'], + await rootBundle.loadString(flagIconsLicenseAsset), + ); +}); + +/// Where the notice is, among this package's assets. +const flagIconsLicenseAsset = + 'packages/halkyon_app/assets/flags/LICENSE-flag-icons.txt'; diff --git a/packages/halkyon_app/lib/src/instance/instance.dart b/packages/halkyon_app/lib/src/instance/instance.dart new file mode 100644 index 0000000..a7a2fdc --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +/// An instance of a Halkyon app: a server people keep their data on, by its +/// address (`https://learn.halkyon.eu/`). +final class const Instance._( + /// Its address: HTTPS unless the person said otherwise, with a path + /// ending in `/`, and nothing after it. + final Uri url, +) with Equatable { + /// The instance at [address] as a person types it or a build names it: + /// `learn.example.org`, with `https://` or without, with a port or a path + /// or not; null for an address that can't be one. + static Instance? parse(String address) { + var text = address.trim(); + if (text.isEmpty || text.contains(RegExp(r'\s'))) { + return null; + } + if (!text.contains('://')) { + text = 'https://$text'; + } + final url = Uri.tryParse(text); + if (url == null || + !(url.isScheme('https') || url.isScheme('http')) || + url.host.isEmpty || + url.userInfo.isNotEmpty) { + return null; + } + final path = url.path.endsWith('/') ? url.path : '${url.path}/'; + return Instance._( + Uri( + scheme: url.scheme, + host: url.host.toLowerCase(), + port: url.hasPort ? url.port : null, + path: path, + ), + ); + } + + /// How people know it: its host, with the port if it has one + /// (`learn.halkyon.eu`, `localhost:8080`). + String get name => url.hasPort ? '${url.host}:${url.port}' : url.host; + + @override + List get props => [url]; + + @override + String toString() => '$url'; +} diff --git a/packages/halkyon_app/lib/src/instance/instance_checker.dart b/packages/halkyon_app/lib/src/instance/instance_checker.dart new file mode 100644 index 0000000..8984f74 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_checker.dart @@ -0,0 +1,60 @@ +import 'package:halkyon_app/src/base/base_client.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_locations.dart'; +import 'package:halkyon_base_client/halkyon_base_client.dart'; +import 'package:http/http.dart' as http; + +/// Asks instances about themselves before the app moves there: whether one +/// answers at an address, and where it keeps people's data. Tests replace +/// it with a fake. +abstract interface class InstanceChecker() { + /// Whether [instance] answers as an instance of this app (its + /// halkyon_base does, naming the app). + Future answers(Instance instance); + + /// Where [instance] says it keeps people's data and its backups; null + /// where it doesn't answer. + Future locationsOf(Instance instance); +} + +/// Asks the instance about itself (halkyon_base's `instance.basics()`), +/// with a client of its own that holds no session. Only an instance of +/// [app] answers: another app's is no place for this one's people. +class ServerpodInstanceChecker({ + /// The app's id, as its server configures halkyon_base (`learn`). + required final String app, + + /// What the client sends through; tests give a fake one. + final http.Client Function()? _httpClient, +}) implements InstanceChecker { + @override + Future answers(Instance instance) async => + await _basics(instance) != null; + + @override + Future locationsOf(Instance instance) async => + switch (await _basics(instance)) { + final basics? => InstanceLocations( + data: basics.dataLocation, + backups: basics.backupLocation, + ), + null => null, + }; + + Future _basics(Instance instance) async { + final client = BaseClient( + '${instance.url}', + httpClientOverride: _httpClient?.call(), + ); + try { + final basics = await client.modules.instance.basics().timeout( + const Duration(seconds: 15), + ); + return basics.app == app ? basics : null; + } on Object { + return null; + } finally { + client.close(); + } + } +} diff --git a/packages/halkyon_app/lib/src/instance/instance_choice.dart b/packages/halkyon_app/lib/src/instance/instance_choice.dart new file mode 100644 index 0000000..5e6320d --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_choice.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_store.dart'; + +/// The instances this device keeps an account on, and the one the app uses +/// (as Moshidon does): each account stays signed in, with its own folder +/// and session, one in use at a time. Switching back is +/// instant, anonymous accounts included; signing out ends only the one in +/// use, and removing one from the device forgets what it held of it. +class InstanceChoice( + final InstanceStore _store, { + + /// The instance the build names: the one installs used before there was + /// a choice, offered first to new ones. + required final Instance builtIn, + + /// Deletes what the device holds of the account on an instance: its + /// people's folder and its session. + required final Future Function(Instance instance) forget, +}) extends ChangeNotifier { + Instance? _current; + var _known = const []; + + /// The instance in use; null until one is chosen. + Instance? get current => _current; + + /// Those the device keeps an account on, in the order they were added. + List get known => _known; + + /// Reads what was chosen. An install from before there was a choice + /// ([usedBefore]: it has an account already) goes on with [builtIn] + /// without asking. + Future load({required bool usedBefore}) async { + switch (await _store.read()) { + case (:final current, :final known): + _current = current; + _known = known; + case null when usedBefore: + await use(builtIn); + return; + case null: + break; + } + notifyListeners(); + } + + /// Uses [instance], keeping its account on the device from now on. + Future use(Instance instance) async { + _current = instance; + if (!_known.contains(instance)) { + _known = [..._known, instance]; + } + await _store.write((current: instance, known: _known)); + notifyListeners(); + } + + /// Forgets the account on [instance], one not in use: what the device + /// holds of it goes. What it synced stays on the instance. + Future remove(Instance instance) async { + final current = _current; + if (instance == current || current == null) { + return; + } + await forget(instance); + _known = [ + for (final each in _known) + if (each != instance) each, + ]; + await _store.write((current: current, known: _known)); + notifyListeners(); + } +} diff --git a/packages/halkyon_app/lib/src/instance/instance_locations.dart b/packages/halkyon_app/lib/src/instance/instance_locations.dart new file mode 100644 index 0000000..fe753fa --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_locations.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +/// Where an instance keeps people's data and its backups, as its operator +/// says (`instance.basics()`): each a country's code (ISO 3166-1 alpha-2, +/// `CH`) or `EU`, the backups also [noBackups]; null where the instance +/// says nothing. Where the disks are, not which law +/// governs. +final class const InstanceLocations({ + /// Where people's data is. + final String? data, + + /// Where the backups are, or [noBackups]. + final String? backups, +}) with Equatable { + /// What [backups] says of an instance keeping none. + static const noBackups = 'none'; + + /// The European Union's code, ISO 3166's reserved one. + static const europeanUnion = 'EU'; + + @override + List get props => [data, backups]; +} diff --git a/packages/halkyon_app/lib/src/instance/instance_scope.dart b/packages/halkyon_app/lib/src/instance/instance_scope.dart new file mode 100644 index 0000000..77d8b5c --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_scope.dart @@ -0,0 +1,26 @@ +import 'package:halkyon_app/src/instance/country_names.dart'; +import 'package:halkyon_app/src/instance/instance_checker.dart'; +import 'package:halkyon_app/src/instance/instance_choice.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Hands the instances the device keeps accounts on ([InstanceChoice]) to +/// the app, which rebuilds where it depends on them when they change. +class const InstanceScope({ + required super.notifier, + + /// Checks an address before the app moves there. + required final InstanceChecker checker, + + /// Names the countries instances keep their data in. + required final CountryNames countryNames, + required super.child, + super.key, +}) extends InheritedNotifier { + /// The scope above [context]; null where there is none, as in a test of a + /// single screen. + static InstanceScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + /// The instances. + InstanceChoice get choice => notifier!; +} diff --git a/packages/halkyon_app/lib/src/instance/instance_storage.dart b/packages/halkyon_app/lib/src/instance/instance_storage.dart new file mode 100644 index 0000000..387cb91 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_storage.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:path/path.dart' as p; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +/// Where the device keeps what it holds of the account on each instance, in +/// the app's support folder [_support]. The instance the build names keeps +/// it where installs did before there was a choice: `people/`, and the +/// session under Serverpod's own key. Every other has a folder of its own, +/// `instances//`, and a key of its own. +class InstanceStorage( + final String _support, { + + /// The instance the build names. + required final Instance builtIn, +}) { + /// Whether the device had an account before there was a choice: one on + /// [builtIn]. + bool usedBefore() => _peopleOfBuiltIn.existsSync(); + + /// The folder of the people of [instance] (see `PersonFolders`). + Directory peopleOf(Instance instance) => instance == builtIn + ? _peopleOfBuiltIn + : Directory(p.join(_folderOf(instance).path, 'people')); + + /// The key [instance]'s session is kept under; null for Serverpod's own. + String? sessionKeyOf(Instance instance) => instance == builtIn + ? null + : 'serverpod_auth_success_key@${_nameOf(instance)}'; + + /// Deletes what the device holds of the account on [instance]: its + /// people's folder and its session. The session itself stays valid on the + /// instance until it expires. + Future forget(Instance instance) async { + final folder = instance == builtIn ? _peopleOfBuiltIn : _folderOf(instance); + try { + await folder.delete(recursive: true); + } on PathNotFoundException { + // Nothing was kept. + } + await SecureClientAuthSuccessStorage( + authSuccessStorageKey: sessionKeyOf(instance), + ).set(null); + } + + Directory get _peopleOfBuiltIn => Directory(p.join(_support, 'people')); + + Directory _folderOf(Instance instance) => + Directory(p.join(_support, 'instances', _nameOf(instance))); + + /// [instance]'s address as a folder name: `learn.example.org`, + /// `localhost_8080`, `example.org_learn` for one under a path. + static String _nameOf(Instance instance) { + final path = instance.url.path; + return '${instance.name}${path == '/' ? '' : path}' + .replaceAll(RegExp(r'/+$'), '') + .replaceAll(RegExp('[^a-z0-9.-]'), '_'); + } +} diff --git a/packages/halkyon_app/lib/src/instance/instance_store.dart b/packages/halkyon_app/lib/src/instance/instance_store.dart new file mode 100644 index 0000000..d0b0272 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/instance_store.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_core/halkyon_core.dart'; + +/// The instances the device keeps an account on, in the order they were +/// added, and the one in use. +typedef InstanceList = ({Instance current, List known}); + +/// Where the device keeps which instances it has accounts on. +abstract interface class InstanceStore() { + /// The instances kept; null when none was chosen yet, or the list can't + /// be read. + Future read(); + + /// Keeps [instances]. + Future write(InstanceList instances); +} + +/// Keeps them as JSON in [_file], beside the people's folders: they are the +/// device's, not a person's. Written to a temporary file first and renamed +/// into place. +class FileInstanceStore(final File _file) implements InstanceStore { + // One write at a time: they share the temporary file. + final _queue = SerialQueue(); + + @override + Future read() async { + try { + if (jsonDecode(await _file.readAsString()) case { + 'current': final String current, + 'known': final List known, + }) { + final instances = [ + for (final address in known.whereType()) + ?Instance.parse(address), + ]; + if (Instance.parse(current) case final current?) { + return ( + current: current, + known: [ + ...instances, + if (!instances.contains(current)) current, + ], + ); + } + } + return null; + } on FileSystemException { + return null; + } on FormatException catch (error) { + log('The instances are unreadable', name: 'Instances', error: error); + return null; + } + } + + @override + Future write(InstanceList instances) => _queue.run(() async { + await _file.parent.create(recursive: true); + final temporary = File('${_file.path}.new'); + await temporary.writeAsString( + jsonEncode({ + 'current': '${instances.current}', + 'known': [for (final instance in instances.known) '$instance'], + }), + flush: true, + ); + await temporary.rename(_file.path); + }); +} diff --git a/packages/halkyon_app/lib/src/instance/widget/account_switcher_sheet.dart b/packages/halkyon_app/lib/src/instance/widget/account_switcher_sheet.dart new file mode 100644 index 0000000..61f07f4 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/account_switcher_sheet.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flag/flag.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/auth/bloc/session_bloc.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_choice.dart'; +import 'package:halkyon_app/src/instance/instance_scope.dart'; +import 'package:halkyon_app/src/instance/widget/instance_location.dart'; +import 'package:halkyon_app/src/instance/widget/instance_step.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The accounts on this device, one per instance, the one in use marked, as +/// Mastodon's apps list them, each with where its instance keeps people's +/// data once it says (its flag in place of the initial): tapping another +/// switches to it, its trailing button removes it from the device, and Add +/// account adds one on another instance. +Future showAccountSwitcher(BuildContext context) => showAdaptiveSheet( + context, + builder: (sheetContext) => const _AccountSwitcherSheet(), +); + +class const _AccountSwitcherSheet() extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + final choice = InstanceScope.maybeOf(context)!.choice; + return SheetScaffold( + title: l10n.accountsTitle, + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final instance in choice.known) + _AccountTile( + instance: instance, + inUse: instance == choice.current, + ), + const Divider(height: 1), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.plus), + title: Text(l10n.accountAdd), + onTap: () { + Navigator.of(context) + ..pop() + ..push( + MaterialPageRoute( + builder: (routeContext) => const AddAccountScreen(), + ), + ); + }, + ), + ], + ), + ); + } +} + +class const _AccountTile({ + required final Instance instance, + required final bool inUse, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = HalkyonAppLocalizations.of(context); + final choice = InstanceScope.maybeOf(context)!.choice; + // Which account is in use there, while someone is signed in. + final account = switch ((inUse, context.watch().state)) { + (true, SessionStateActive(:final person)) => + person.isIdentified ? l10n.accountSignedIn : l10n.accountAnonymous, + _ => null, + }; + return InstanceLocationBuilder( + instance: instance, + builder: (context, flag, words) => ListTile( + contentPadding: EdgeInsets.zero, + // The flag of where the instance keeps people's data, once it says. + leading: flag != null && Flag.flagsCode.contains(flag.toLowerCase()) + ? InstanceFlag(flag, size: 40) + : CircleAvatar( + child: Text(instance.name.characters.first.toUpperCase()), + ), + title: Text(instance.name), + subtitle: switch ([?account, ?words]) { + [] => null, + final lines => Text(lines.join('\n')), + }, + isThreeLine: account != null && (words?.contains('\n') ?? false), + trailing: inUse + ? Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary) + : IconButton( + icon: const Icon(LucideIcons.x), + tooltip: l10n.accountRemoveTooltip, + onPressed: () => _remove(context, choice), + ), + // The sheet goes first, so the app rebuilding on the other instance + // doesn't meet an open route. + onTap: inUse + ? null + : () { + Navigator.of(context).pop(); + unawaited(choice.use(instance)); + }, + ), + ); + } + + Future _remove(BuildContext context, InstanceChoice choice) async { + final l10n = HalkyonAppLocalizations.of(context); + final navigator = Navigator.of(context); + if (await confirmDestructiveAction( + context, + title: l10n.accountRemoveTitle, + message: l10n.accountRemoveMessage(instance.name), + confirmLabel: l10n.accountRemoveConfirm, + )) { + navigator.pop(); + await choice.remove(instance); + } + } +} + +/// Adds an account on another instance: the instance chosen, the app moves +/// there and starts an anonymous account, as on a first start, unless the +/// device has one there already. +class const AddAccountScreen({super.key}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + final scope = InstanceScope.maybeOf(context)!; + return Scaffold( + appBar: AppBar(title: Text(l10n.accountAdd)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: InstanceStep( + builtIn: scope.choice.builtIn, + checker: scope.checker, + subtitle: l10n.accountAddSubtitle, + // The app rebuilds on the instance, without this screen. + onChosen: scope.choice.use, + ), + ), + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/instance/widget/first_start_screen.dart b/packages/halkyon_app/lib/src/instance/widget/first_start_screen.dart new file mode 100644 index 0000000..cbbcce0 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/first_start_screen.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/instance/instance_scope.dart'; +import 'package:halkyon_app/src/instance/widget/instance_location.dart'; +import 'package:halkyon_app/src/instance/widget/instance_step.dart'; +import 'package:halkyon_app/src/instance/widget/instance_tile.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The first screen of a new install: which instance to keep the person's +/// data on. Nothing is made anywhere until they choose, not even an +/// anonymous account. Under the [BrandWordmark] on a cobalt wash, two +/// [InstanceTile]s: the build's instance, chosen in one tap, with where it +/// says it keeps people's data once it answers (a flag, and the tagline), +/// and another, which unfolds the address to type. +class const FirstStartScreen({super.key}) extends StatefulWidget { + @override + State createState() => _FirstStartScreenState(); +} + +class _FirstStartScreenState() extends State { + /// Whether the address of another instance is asked for. + var _another = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = HalkyonAppLocalizations.of(context); + final scope = InstanceScope.maybeOf(context)!; + final frame = AppFrameScope.of(context); + final builtIn = scope.choice.builtIn; + return AnnotatedRegion( + value: TopGradient.overlayStyle(context), + child: TopGradient( + color: Brand.primary, + background: theme.colorScheme.surface, + extent: 320, + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + label: l10n.instanceWelcome(frame.name(context)), + child: BrandWordmark(product: frame.product(context)), + ), + const SizedBox(height: 16), + Text( + frame.firstStartSubtitle(context), + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: InstanceLocationBuilder( + instance: builtIn, + builder: (context, flag, words) => InstanceTile( + icon: Image( + image: ResizeImage.resizeIfNeeded( + // The app's icon may be large (Halkyon + // Learn's is 1024 px square); no more is + // needed in memory, even at three times + // the density. + (InstanceTile.iconSize * 3).round(), + null, + frame.icon, + ), + width: InstanceTile.iconSize, + height: InstanceTile.iconSize, + ), + label: builtIn.name, + tagline: words ?? l10n.instanceBuiltInTagline, + badge: flag == null ? null : InstanceFlag(flag), + onTap: () => + unawaited(scope.choice.use(builtIn)), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: InstanceTile( + icon: const _BrandIcon(LucideIcons.globe), + label: l10n.instanceAnother, + tagline: l10n.instanceAnotherTagline, + expanded: _another, + onTap: () => setState(() => _another = !_another), + ), + ), + ], + ), + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : InstanceTile.duration, + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _another + ? Padding( + padding: const EdgeInsets.only(top: 32), + child: InstanceStep( + builtIn: builtIn, + checker: scope.checker, + offerBuiltIn: false, + autofocus: true, + onChosen: scope.choice.use, + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// [icon] in the kingfisher's blues, as the icon has them: cobalt into sky +/// blue. Its orange turns muddy in thin strokes, after either. +class const _BrandIcon(final IconData icon) extends StatelessWidget { + static const _colors = [Color(0xFF3C57E5), Color(0xFF179FD9)]; + + @override + Widget build(BuildContext context) => ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _colors, + ).createShader(bounds), + child: Icon(icon, size: 44, color: Colors.white), + ); +} diff --git a/packages/halkyon_app/lib/src/instance/widget/instance_location.dart b/packages/halkyon_app/lib/src/instance/widget/instance_location.dart new file mode 100644 index 0000000..7e80318 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/instance_location.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flag/flag.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_locations.dart'; +import 'package:halkyon_app/src/instance/instance_scope.dart'; +import 'package:halkyon_app/src/instance/widget/instance_location_words.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Where [instance] says it keeps people's data, asked of it as this is +/// first built: [builder] gets the code of the flag to show (where the data +/// is) and the words ([instanceLocationWords], the countries named in the +/// app's language) once it answers, nulls until then and where it says +/// nothing. +class const InstanceLocationBuilder({ + required final Instance instance, + required final Widget Function( + BuildContext context, + String? flag, + String? words, + ) + builder, + super.key, +}) extends StatefulWidget { + @override + State createState() => + _InstanceLocationBuilderState(); +} + +class _InstanceLocationBuilderState() extends State { + InstanceLocations? _locations; + var _names = const {}; + + /// The language the names were asked in. + String? _language; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final language = Localizations.localeOf(context).languageCode; + if (language != _language) { + _language = language; + unawaited(_ask(InstanceScope.maybeOf(context)!, language)); + } + } + + Future _ask(InstanceScope scope, String language) async { + final locations = await scope.checker.locationsOf(widget.instance); + if (locations == null) { + return; + } + final names = {}; + for (final code in {locations.data, locations.backups}.nonNulls) { + if (await scope.countryNames.nameOf(code, language) case final name?) { + names[code] = name; + } + } + if (mounted && language == _language) { + setState(() { + _locations = locations; + _names = names; + }); + } + } + + @override + Widget build(BuildContext context) { + final locations = _locations; + return widget.builder( + context, + locations?.data, + locations == null + ? null + : instanceLocationWords( + HalkyonAppLocalizations.of(context), + locations, + _names, + ), + ); + } +} + +/// The flag of [code] (a country's code, or `EU`) in a white ring, [size] +/// across; nothing for a code without a flag. Screen readers hear the +/// place in the words beside it instead. +class const InstanceFlag( + final String code, { + final double size = 26, + super.key, +}) extends StatelessWidget { + /// How wide the white ring is. + static const ring = 2.0; + + @override + Widget build(BuildContext context) { + if (!Flag.flagsCode.contains(code.toLowerCase())) { + return const SizedBox.shrink(); + } + const inner = 2 * ring; + return ExcludeSemantics( + child: Container( + width: size, + height: size, + padding: const EdgeInsets.all(ring), + decoration: const BoxDecoration( + // White in both themes, as the tiles are. + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 4, + offset: Offset(0, 1), + ), + ], + ), + child: Flag.fromString( + code, + flagSize: FlagSize.size_1x1, + width: size - inner, + height: size - inner, + borderRadius: (size - inner) / 2, + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/instance/widget/instance_location_words.dart b/packages/halkyon_app/lib/src/instance/widget/instance_location_words.dart new file mode 100644 index 0000000..8727991 --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/instance_location_words.dart @@ -0,0 +1,30 @@ +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/instance/instance_locations.dart'; + +/// Where [locations] says an instance keeps people's data and its backups, +/// in words, the countries by their [names] (by code; one without goes by +/// its code): "Data and backups: European Union", else a line each, "Data: +/// European Union" over "Backups: Switzerland" or "No backups"; null where +/// it says nothing. Labels rather than sentences, since "in" would need the +/// article some countries take (in the Netherlands, in der Schweiz). +String? instanceLocationWords( + HalkyonAppLocalizations l10n, + InstanceLocations locations, + Map names, +) { + String place(String code) => code == InstanceLocations.europeanUnion + ? l10n.instanceEuropeanUnion + : names[code] ?? code; + final InstanceLocations(:data, :backups) = locations; + if (data != null && data == backups) { + return l10n.instanceDataAndBackupsIn(place(data)); + } + final lines = [ + if (data != null) l10n.instanceDataIn(place(data)), + if (backups == InstanceLocations.noBackups) + l10n.instanceNoBackups + else if (backups != null) + l10n.instanceBackupsIn(place(backups)), + ]; + return lines.isEmpty ? null : lines.join('\n'); +} diff --git a/packages/halkyon_app/lib/src/instance/widget/instance_step.dart b/packages/halkyon_app/lib/src/instance/widget/instance_step.dart new file mode 100644 index 0000000..bb87d9b --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/instance_step.dart @@ -0,0 +1,156 @@ +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_checker.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Choosing an instance, as Mastodon's apps do: its address typed, checked +/// to answer as an instance, or the one the build names in one tap (unless +/// the screen offers it otherwise, [offerBuiltIn]). [onChosen] gets the +/// instance. +class const InstanceStep({ + /// The instance the build names, offered in one tap. + required final Instance builtIn, + + /// Checks a typed address. + required final InstanceChecker checker, + + /// What happens with the instance chosen. + required final ValueChanged onChosen, + + /// A line above the address: what the choice is for. + final String? subtitle, + + /// Whether a button offers the build's instance; the first start offers + /// it as a tile of its own. + final bool offerBuiltIn = true, + + /// Whether the address has the focus at once. + final bool autofocus = false, + super.key, +}) extends StatefulWidget { + @override + State createState() => _InstanceStepState(); +} + +class _InstanceStepState() extends State { + // Marked as required while empty, as it is typed. + late final _address = TextEditingController() + ..addListener(() => setState(() {})); + var _checking = false; + String? _error; + + @override + void dispose() { + _address.dispose(); + super.dispose(); + } + + Future _continue() async { + final l10n = HalkyonAppLocalizations.of(context); + final text = _address.text.trim(); + if (text.isEmpty) { + setState(() => _error = l10n.instanceAddressRequired); + return; + } + final instance = Instance.parse(text); + if (instance == null) { + setState(() => _error = l10n.instanceAddressInvalid); + return; + } + // The build's own needs no asking. + if (instance == widget.builtIn) { + widget.onChosen(instance); + return; + } + setState(() { + _checking = true; + _error = null; + }); + final answers = await widget.checker.answers(instance); + if (!mounted) { + return; + } + setState(() { + _checking = false; + if (!answers) { + _error = l10n.instanceUnreachable( + AppFrameScope.of(context).name(context), + instance.name, + ); + } + }); + if (answers) { + widget.onChosen(instance); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = HalkyonAppLocalizations.of(context); + final subtitle = widget.subtitle; + final error = _error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (subtitle != null) ...[ + Text( + subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + ], + TextField( + maxLength: TextLimits.address, + buildCounter: nearLimitCounter, + controller: _address, + autofocus: widget.autofocus, + enabled: !_checking, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + decoration: requiredDecoration( + context, + InputDecoration( + labelText: l10n.instanceAddress, + hintText: AppFrameScope.of(context).exampleAddress, + border: const OutlineInputBorder(), + ), + missing: _address.text.trim().isEmpty, + ), + onSubmitted: (_) => _checking ? null : _continue(), + ), + if (widget.offerBuiltIn) ...[ + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: _checking + ? null + : () => widget.onChosen(widget.builtIn), + child: Text(l10n.instanceUseBuiltIn(widget.builtIn.name)), + ), + ), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text(error, style: TextStyle(color: theme.colorScheme.error)), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _checking ? null : _continue, + child: _checking + ? const ButtonLoadingIndicator() + : Text(l10n.instanceContinue), + ), + ], + ); + } +} diff --git a/packages/halkyon_app/lib/src/instance/widget/instance_tile.dart b/packages/halkyon_app/lib/src/instance/widget/instance_tile.dart new file mode 100644 index 0000000..ad09c8f --- /dev/null +++ b/packages/halkyon_app/lib/src/instance/widget/instance_tile.dart @@ -0,0 +1,239 @@ +import 'package:flutter/rendering.dart' show MouseTracker, RendererBinding; +import 'package:material_ui/material_ui.dart'; + +/// A choice shown as a tile, as Proton's account page lists its services: +/// [icon] on a white rounded square above [label]. While the pointer is +/// over it, the keyboard's focus is on it, it is +/// pressed or what it opens is [expanded], a ring running from the primary +/// colour to the tertiary (cobalt to copper, as the kingfisher's wings) +/// comes in around the square with a glow of both beneath it, and [tagline] +/// under the label. Without a mouse, which can't hover, the tagline shows +/// from the start. A [badge] sits over the square's corner, as Proton's +/// "New" does, fading in when it comes. +class const InstanceTile({ + /// What the square shows, at most [iconSize] across. + required final Widget icon, + + /// The choice's name. + required final String label, + + /// A few words on it. + required final String tagline, + + /// What choosing it does. + required final VoidCallback onTap, + + /// Whether what it opens is open; null where it opens nothing. + final bool? expanded, + + /// What sits over the square's corner, such as the flag of where an + /// instance keeps its data. + final Widget? badge, + super.key, +}) extends StatefulWidget { + /// How large the square is. + static const size = 88.0; + + /// How large what it shows may be. + static const iconSize = size - 2 * ringWidth; + + /// How wide the ring is. + static const ringWidth = 2.5; + + /// How wide the tile is at most, its label and tagline included. + static const maxWidth = 176.0; + + /// How long the ring and the tagline take to come in or go. + static const duration = Duration(milliseconds: 200); + + /// The ring with its glow. + @visibleForTesting + static const ringKey = Key('instanceTileRing'); + + @override + State createState() => _InstanceTileState(); +} + +class _InstanceTileState() extends State { + final MouseTracker _mice = RendererBinding.instance.mouseTracker; + var _hovered = false; + var _focused = false; + var _pressed = false; + + @override + void initState() { + super.initState(); + _mice.addListener(_miceChanged); + } + + @override + void dispose() { + _mice.removeListener(_miceChanged); + super.dispose(); + } + + // A mouse plugged in or out: the tagline waits for the pointer, or not. + void _miceChanged() => setState(() {}); + + void _press(bool pressed) => setState(() => _pressed = pressed); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final lit = _hovered || _focused || _pressed || (widget.expanded ?? false); + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : InstanceTile.duration; + final outer = BorderRadius.circular(InstanceTile.size * 0.22); + final inner = BorderRadius.circular( + InstanceTile.size * 0.22 - InstanceTile.ringWidth, + ); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InstanceTile.maxWidth), + child: MergeSemantics( + child: Semantics( + button: true, + expanded: widget.expanded, + // The pointer over it, as Proton's page has it: a hover + // highlight would wait for a click on a phone with a mouse. + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: FocusableActionDetector( + onShowFocusHighlight: (focused) => + setState(() => _focused = focused), + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + widget.onTap(); + return null; + }, + ), + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => _press(true), + onTapUp: (_) => _press(false), + onTapCancel: () => _press(false), + onTap: widget.onTap, + child: TweenAnimationBuilder( + tween: Tween(end: lit ? 1 : 0), + duration: duration, + curve: Curves.easeOut, + builder: (context, glow, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: InstanceTile.size, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + Opacity( + key: InstanceTile.ringKey, + opacity: glow, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: outer, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.primary, scheme.tertiary], + ), + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues( + alpha: 0.35, + ), + blurRadius: 20, + offset: const Offset(-4, 4), + ), + BoxShadow( + color: scheme.tertiary.withValues( + alpha: 0.3, + ), + blurRadius: 20, + offset: const Offset(4, 8), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all( + InstanceTile.ringWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + // White in both themes, as the kingfisher's + // own background is. + color: Colors.white, + borderRadius: inner, + // At rest a faint shadow of the primary + // colour, giving way to the glow. + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues( + alpha: 0.12 * (1 - glow), + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: inner, + child: Center(child: widget.icon), + ), + ), + ), + PositionedDirectional( + top: -6, + end: -6, + child: AnimatedSwitcher( + duration: duration, + child: + widget.badge ?? const SizedBox.shrink(), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Opacity( + opacity: _mice.mouseIsConnected ? glow : 1, + // Heard whether it shows or not. + alwaysIncludeSemantics: true, + child: Text( + widget.tagline, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/person/person_folders.dart b/packages/halkyon_app/lib/src/person/person_folders.dart new file mode 100644 index 0000000..9490229 --- /dev/null +++ b/packages/halkyon_app/lib/src/person/person_folders.dart @@ -0,0 +1,28 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Each person's data on this device lives in a folder of its own under +/// [_root], and one person uses the device at a time. +class PersonFolders(final Directory _root) { + /// The folder of [authUserId], created if needed, once everyone else's + /// folders are gone: their sessions ended on this device, whether they + /// signed out, switched, were revoked or deleted. What can't be deleted + /// now (a file still open) goes next time. + Future openOnly(String authUserId) async { + await _root.create(recursive: true); + await for (final entry in _root.list()) { + if (entry is Directory && p.basename(entry.path) != authUserId) { + try { + await entry.delete(recursive: true); + } on FileSystemException catch (error) { + log('Could not wipe ${entry.path}', name: 'People', error: error); + } + } + } + return await Directory( + p.join(_root.path, authUserId), + ).create(recursive: true); + } +} diff --git a/packages/halkyon_app/lib/src/sync/app_compatibility.dart b/packages/halkyon_app/lib/src/sync/app_compatibility.dart new file mode 100644 index 0000000..98825e0 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/app_compatibility.dart @@ -0,0 +1,53 @@ +import 'package:halkyon_app/src/sync/sync_service.dart'; +import 'package:serverpod_offline_sync_client/offline_sync.dart'; + +/// This app and the server don't match: the server needs a newer app, or the +/// two disagree on the synced tables (one of them needs an update). +final class const AppServerMismatchException() implements Exception { + @override + String toString() => + "AppServerMismatchException: this app and the server don't match"; +} + +/// Whether the server still works with this app's build, asked once per +/// launch: published apps can't be made to update, so the server names the +/// oldest build it works with (`minimumAppBuild` in `instance.info()`). +class AppCompatibility({ + /// This app's build (the number after `+` in its version), or null when it + /// has none, as in development: such a build is never refused. + required final int? appBuild, + + /// Asks the server for the oldest build it works with; null for any. + required final Future Function() minimumAppBuild, +}) { + bool? _compatible; + + /// Throws [AppServerMismatchException] if the server no longer works with + /// this build. A failure to ask passes on, and the next check asks again. + Future check() async { + final build = appBuild; + if (build == null) { + return; + } + final compatible = _compatible ??= switch (await minimumAppBuild()) { + null => true, + final minimum => build >= minimum, + }; + if (!compatible) { + throw const AppServerMismatchException(); + } + } +} + +/// [sync], checked with [compatibility] first; a sync the engine refuses +/// because the synced tables differ from the server's also ends in +/// [AppServerMismatchException]. +SyncOnce compatibleSync(SyncOnce sync, AppCompatibility compatibility) => + ({required onMerge}) async { + await compatibility.check(); + try { + await sync(onMerge: onMerge); + } on OfflineSyncTablesHashMismatchException { + throw const AppServerMismatchException(); + } + }; diff --git a/packages/halkyon_app/lib/src/sync/bloc/sync_status_bloc.dart b/packages/halkyon_app/lib/src/sync/bloc/sync_status_bloc.dart new file mode 100644 index 0000000..f532052 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/bloc/sync_status_bloc.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/src/sync/sync_service.dart'; +import 'package:halkyon_app/src/sync/sync_status.dart'; + +/// Where sync stands on this device, for the trouble dot, the Settings card +/// and the banner on Home; and syncing now when the person asks. +class SyncStatusBloc(final SyncService _sync) + extends Bloc { + this : super(_sync.status) { + on( + (event, emit) => emit.forEach( + _sync.statusChanges, + onData: (status) => status, + ), + ); + on((event, emit) => _sync.syncNow()); + } +} + +/// What happens to the sync status. +sealed class const SyncStatusEvent() with Equatable { + @override + List get props => const []; +} + +/// The person's data opened: follow the sync. +final class const SyncStatusEventStarted() extends SyncStatusEvent; + +/// The person asked to sync now. +final class const SyncStatusEventSyncRequested() extends SyncStatusEvent; diff --git a/packages/halkyon_app/lib/src/sync/pending_changes_store.dart b/packages/halkyon_app/lib/src/sync/pending_changes_store.dart new file mode 100644 index 0000000..323d9fa --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/pending_changes_store.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:halkyon_core/halkyon_core.dart'; + +/// What sync keeps for a person on the device: how many of their changes +/// haven't reached the server yet, and whether a sync ever went through +/// here (before it has, an empty library may be on its way). +typedef KeptSync = ({int pending, bool synced}); + +/// Where the device keeps [KeptSync], so it outlives the app. +abstract interface class PendingChangesStore() { + /// What is kept: none pending and never synced when there is nothing or + /// it can't be read. + Future read(); + + /// Keeps [kept]. + Future write(KeptSync kept); +} + +/// Keeps the count as JSON in [_file], in the person's folder, which goes +/// with them; written to a temporary file first and renamed into place. +class FilePendingChangesStore(final File _file) implements PendingChangesStore { + // One write at a time: they share the temporary file. + final _queue = SerialQueue(); + + @override + Future read() async { + try { + return switch (jsonDecode(await _file.readAsString())) { + // A file from before `synced` was kept is a device's that synced. + {'pending': final int count} && final json when count >= 0 => ( + pending: count, + synced: json['synced'] != false, + ), + _ => (pending: 0, synced: true), + }; + } on FileSystemException { + return (pending: 0, synced: false); + } on FormatException catch (error) { + log('The pending changes are unreadable', name: 'Sync', error: error); + return (pending: 0, synced: true); + } + } + + @override + Future write(KeptSync kept) => _queue.run(() async { + await _file.parent.create(recursive: true); + final temporary = File('${_file.path}.new'); + await temporary.writeAsString( + jsonEncode({'pending': kept.pending, 'synced': kept.synced}), + flush: true, + ); + await temporary.rename(_file.path); + }); +} diff --git a/packages/halkyon_app/lib/src/sync/sync_service.dart b/packages/halkyon_app/lib/src/sync/sync_service.dart new file mode 100644 index 0000000..9bc4ba0 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/sync_service.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/widgets.dart'; +import 'package:halkyon_app/src/sync/app_compatibility.dart'; +import 'package:halkyon_app/src/sync/pending_changes_store.dart'; +import 'package:halkyon_app/src/sync/sync_status.dart'; +import 'package:halkyon_core/halkyon_core.dart'; + +/// Syncs once and calls `onMerge` whenever changes from other devices arrive. +typedef SyncOnce = Future Function({required VoidCallback onMerge}); + +/// Keeps one person's local database in sync, on triggers: once at start, +/// shortly after local changes, when the app resumes, and every few minutes. +/// +/// Continuous sync (the engine's live mode) comes once it has been hardened. +/// A failed sync, for example without a connection, is logged and retried on +/// the next trigger; [status] tells how the last one went. +/// +/// It counts the changes made here that haven't reached the server: every +/// save asks for a sync ([requestSync]), and a sync that goes through takes +/// those made before it started. The engine itself can't say, as the server +/// only tells it what it has at the start of each sync. [pendingChanges] +/// keeps the count while the app is closed. +class SyncService( + final SyncOnce _syncOnce, { + + /// How long after a local change the sync waits for further changes. + final Duration debounce = const Duration(seconds: 2), + + /// How often it syncs while nothing else triggers it. + final Duration interval = const Duration(minutes: 5), + + /// Where the count of changes not on the server yet is kept; only in + /// memory when null. + final PendingChangesStore? pendingChanges, + + /// Whether a sync went through on this device before, as [pendingChanges] + /// kept it: read as the person's data opens, so the first status has it. + final bool syncedBefore = false, +}) { + final _merges = StreamController.broadcast(); + final _synced = StreamController.broadcast(); + final _statusChanges = StreamController.broadcast(); + late var _status = SyncStatus(syncedHere: syncedBefore); + Timer? _debounceTimer; + Timer? _periodic; + AppLifecycleListener? _lifecycle; + bool _stopping = false; + + /// One sync at a time; a trigger during a sync runs one more afterwards. + late final _syncs = CoalescingRunner( + _syncTriggered, + onError: (error, st) => log( + 'Syncing stopped', + name: 'SyncService', + error: error, + stackTrace: st, + ), + ); + + /// The changes made here not on the server yet. + var _pending = 0; + + /// Reading the count kept from before: begun at [start] or by the first + /// sync, whichever comes first, and done before that sync, which takes + /// those changes too. + late final Future _restoring = _restore(); + + /// Keeps the count, one write at a time and the latest last: a burst of + /// saves writes it twice, not once each. + late final _writes = CoalescingRunner( + () async { + // Once stopping, the person's folder may be about to go. + if (pendingChanges case final store? when !_stopping) { + await store.write((pending: _pending, synced: _status.syncedHere)); + } + }, + onError: (error, _) => + log('Keeping the pending changes failed', name: 'Sync', error: error), + ); + + /// Fires whenever a sync brought changes from other devices. + Stream get merges => _merges.stream; + + /// Fires after every sync that went through: this device's changes are on + /// the server now, and there is a connection. + Stream get synced => _synced.stream; + + /// Where sync stands on this device since the app started. + SyncStatus get status => _status; + + /// Every change of [status]. + Stream get statusChanges => _statusChanges.stream; + + /// Syncs now, then on the triggers. + void start() { + unawaited(_restoring); + _periodic = Timer.periodic(interval, (_) => _run()); + _lifecycle = AppLifecycleListener(onResume: _run); + _run(); + } + + /// Syncs shortly, once local changes have settled; a change was saved. + void requestSync() { + _setPending(_pending + 1); + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounce, _run); + } + + /// Syncs now, without waiting for local changes to settle: the person asked. + void syncNow() { + _debounceTimer?.cancel(); + _run(); + } + + /// Stops syncing. A sync in progress finishes first, so the database can be + /// closed once this completes. + Future dispose() async { + _stopping = true; + _debounceTimer?.cancel(); + _periodic?.cancel(); + _lifecycle?.dispose(); + await _syncs.idle; + await _merges.close(); + await _synced.close(); + await _statusChanges.close(); + } + + void _run() { + if (!_stopping) { + unawaited(_syncs.run()); + } + } + + Future _syncTriggered() async { + await _restoring; + if (_stopping) { + return; + } + _report( + SyncStatus( + syncing: true, + lastSyncedAt: _status.lastSyncedAt, + failure: _status.failure, + pending: _pending, + syncedHere: _status.syncedHere, + ), + ); + // Those saved before the sync starts go with it; those saved while it + // runs, perhaps too late for it, wait for the next. + final sending = _pending; + try { + await _syncOnce(onMerge: () => _merges.add(null)); + _pending -= sending; + _report( + SyncStatus( + lastSyncedAt: clock.now(), + pending: _pending, + syncedHere: true, + ), + ); + _persistPending(); + _synced.add(null); + } on Object catch (error, st) { + log('Sync failed', name: 'SyncService', error: error, stackTrace: st); + _report( + SyncStatus( + lastSyncedAt: _status.lastSyncedAt, + failure: ( + at: clock.now(), + appMismatch: error is AppServerMismatchException, + ), + pending: _pending, + syncedHere: _status.syncedHere, + ), + ); + } + } + + void _report(SyncStatus status) { + _status = status; + if (!_statusChanges.isClosed) { + _statusChanges.add(status); + } + } + + Future _restore() async { + final kept = (await pendingChanges?.read())?.pending ?? 0; + if (kept > 0) { + _setPending(_pending + kept); + } + } + + void _setPending(int pending) { + _pending = pending; + _report( + SyncStatus( + syncing: _status.syncing, + lastSyncedAt: _status.lastSyncedAt, + failure: _status.failure, + pending: pending, + syncedHere: _status.syncedHere, + ), + ); + _persistPending(); + } + + void _persistPending() { + if (pendingChanges != null && !_stopping) { + unawaited(_writes.run()); + } + } +} diff --git a/packages/halkyon_app/lib/src/sync/sync_status.dart b/packages/halkyon_app/lib/src/sync/sync_status.dart new file mode 100644 index 0000000..839d2c1 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/sync_status.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; + +/// A failed sync: when, and whether because this app and the server don't +/// match. +typedef SyncFailure = ({DateTime at, bool appMismatch}); + +/// Where sync stands on this device since the app started. Only the most +/// recent outcome counts: one sync that goes through clears a failure. +final class const SyncStatus({ + /// Whether a sync is running now. + final bool syncing = false, + + /// When a sync last went through since the app started, or null. + final DateTime? lastSyncedAt, + + /// How the most recent sync failed, or null if it went through (or none + /// has run yet). + final SyncFailure? failure, + + /// How many changes made on this device haven't reached the server yet, + /// as it counts them: the saves since the last sync that went through. + final int pending = 0, + + /// Whether a sync ever went through on this device for this person, this + /// run or before: until one has, their library may be on its way. + final bool syncedHere = false, +}) with Equatable { + /// Whether sync needs the person's attention: its last outcome was a + /// failure. A sync running meanwhile doesn't hide it. + bool get hasTrouble => failure != null; + + /// Whether the last sync failed because this app and the server don't + /// match: one of them needs an update. + bool get appMismatch => failure?.appMismatch ?? false; + + @override + List get props => [ + syncing, + lastSyncedAt, + failure, + pending, + syncedHere, + ]; +} diff --git a/packages/halkyon_app/lib/src/sync/widget/app_mismatch_banner.dart b/packages/halkyon_app/lib/src/sync/widget/app_mismatch_banner.dart new file mode 100644 index 0000000..b724a4b --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/widget/app_mismatch_banner.dart @@ -0,0 +1,39 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A banner while this app and the server don't match, so the person updates +/// the app: unlike a passing failure, it doesn't go by itself. More +/// ([onMore]) leads to what explains it, such as the Sync card in Settings. +/// Nothing otherwise. +class const AppMismatchBanner({ + /// Shows more about it. + required final VoidCallback onMore, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final mismatch = context.select( + (bloc) => bloc.state.appMismatch, + ); + if (!mismatch) { + return const SizedBox.shrink(); + } + final l10n = HalkyonAppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + return MaterialBanner( + backgroundColor: colors.errorContainer, + leading: Icon(LucideIcons.cloudAlert, color: colors.onErrorContainer), + content: Text( + l10n.syncMismatchBanner(AppScope.of(context).serverName), + style: TextStyle(color: colors.onErrorContainer), + ), + actions: [ + TextButton(onPressed: onMore, child: Text(l10n.syncMismatchMore)), + ], + ); + } +} diff --git a/packages/halkyon_app/lib/src/sync/widget/sync_and_wait.dart b/packages/halkyon_app/lib/src/sync/widget/sync_and_wait.dart new file mode 100644 index 0000000..921fc01 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/widget/sync_and_wait.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart'; + +/// Syncs now through [bloc] and completes once that sync is over, however it +/// went, or after [timeout]: what a pull to refresh shows its spinner for. +Future syncAndWait( + SyncStatusBloc bloc, { + Duration timeout = const Duration(seconds: 30), +}) { + final done = Completer(); + var started = false; + final subscription = bloc.stream.listen((status) { + if (status.syncing) { + started = true; + } else if (started && !done.isCompleted) { + done.complete(); + } + }); + bloc.add(const SyncStatusEventSyncRequested()); + return done.future + .timeout(timeout, onTimeout: () {}) + // Not waited for: a broadcast stream's cancel never completes under a + // widget test's fake clock. + .whenComplete(() => unawaited(subscription.cancel())); +} diff --git a/packages/halkyon_app/lib/src/sync/widget/sync_status_card.dart b/packages/halkyon_app/lib/src/sync/widget/sync_status_card.dart new file mode 100644 index 0000000..8442cac --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/widget/sync_status_card.dart @@ -0,0 +1,102 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart'; +import 'package:halkyon_app/src/sync/sync_status.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The Sync card in Settings: where sync stands, in one line and a detail, +/// and Sync now. The one place that answers whether the person's changes +/// reach the server, and how many haven't yet. +class const SyncStatusCard({super.key}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = HalkyonAppLocalizations.of(context); + final theme = Theme.of(context); + final server = AppScope.of(context).serverName; + return BlocBuilder( + builder: (context, status) { + String time(DateTime at) => + DateFormat.jm(l10n.localeName).format(at.toLocal()); + final completed = switch (status.lastSyncedAt) { + final at? => l10n.syncLastCompleted(time(at)), + null => l10n.syncNoRunYet, + }; + final pending = switch (status.pending) { + 0 => null, + final count => l10n.syncPending(count), + }; + final (state, details) = switch (status) { + SyncStatus(appMismatch: true) => ( + l10n.syncStateMismatch(server), + [l10n.syncMismatchDetail, ?pending, completed], + ), + SyncStatus(syncing: true, :final lastSyncedAt) => ( + l10n.syncStateSyncing, + [if (lastSyncedAt != null) completed], + ), + SyncStatus(failure: (:final at, appMismatch: _)) => ( + l10n.syncStateFailing, + [l10n.syncLastFailed(time(at)), ?pending, completed], + ), + // Saved since, and on their way once the changes settle. + SyncStatus(lastSyncedAt: _?) when pending != null => ( + pending, + [completed], + ), + SyncStatus(lastSyncedAt: _?) => (l10n.syncStateSynced, [completed]), + _ => (l10n.syncStateNotYet, [?pending]), + }; + return SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.syncTitle, + style: theme.textTheme.titleMedium, + ), + ), + TextButton.icon( + // The state line shows the sync running. + onPressed: status.syncing + ? null + : () => context.read().add( + const SyncStatusEventSyncRequested(), + ), + icon: const Icon(LucideIcons.refreshCw), + label: Text(l10n.syncNow), + ), + ], + ), + const SizedBox(height: 8), + Text( + state, + style: theme.textTheme.bodyLarge?.copyWith( + color: status.hasTrouble && !status.syncing + ? theme.colorScheme.error + : null, + ), + ), + for (final line in details) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + line, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/halkyon_app/lib/src/sync/widget/sync_trouble_badge.dart b/packages/halkyon_app/lib/src/sync/widget/sync_trouble_badge.dart new file mode 100644 index 0000000..e291a61 --- /dev/null +++ b/packages/halkyon_app/lib/src/sync/widget/sync_trouble_badge.dart @@ -0,0 +1,30 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart'; +import 'package:material_ui/material_ui.dart'; + +/// [child] with a small error-coloured dot while the last sync failed, and +/// as it is otherwise: syncing, or everything synced, shows nothing. +/// +/// On the Settings cogs (on Home and Profile), which lead to the Sync card +/// that explains it. +class const SyncTroubleBadge({required final Widget child, super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + final trouble = context.select( + (bloc) => bloc.state.hasTrouble, + ); + if (!trouble) { + return child; + } + return Semantics( + label: HalkyonAppLocalizations.of(context).syncTroubleLabel, + child: Badge( + smallSize: 8, + backgroundColor: Theme.of(context).colorScheme.error, + child: child, + ), + ); + } +} diff --git a/packages/halkyon_app/lib/src/testing/fake_auth_gateway.dart b/packages/halkyon_app/lib/src/testing/fake_auth_gateway.dart new file mode 100644 index 0000000..c10537e --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/fake_auth_gateway.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/auth/model/email_sign_in.dart'; +import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; +import 'package:halkyon_app/src/auth/model/sign_in_ways.dart'; + +/// An [AuthGateway] without a server: signing in creates people named +/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub +/// sign-in links the signed-in person, or switches to `github-person`; with +/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel. Email +/// sign-in mails `CODE-1`, `CODE-2`, … ([mailed]) and links the address, +/// unless [addressOwners] gives it to another person, whose token then +/// switches to them. +class FakeAuthGateway({var AuthStatus _status = const SignedOut()}) + implements AuthGateway { + final _changes = StreamController.broadcast(); + + /// Thrown by the next sign-ins while set. + Exception? signInError; + + /// How often signing in was attempted. + int signIns = 0; + + /// How often signing out was attempted. + int signOuts = 0; + + /// How often the signed-in person's account was deleted. + int accountDeletions = 0; + + /// Whether the instance offers GitHub sign-in. + bool offersGitHub = true; + + /// How the next GitHub links end. + GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked; + + /// Thrown by the next GitHub sign-ins while set. + Exception? gitHubError; + + /// How often this device switched to a GitHub person. + int gitHubSwitches = 0; + + /// Whether the app itself waits for the browser, as on Linux and Windows. + @override + bool canCancelGitHub = false; + + /// While set, GitHub sign-ins wait as if GitHub's page were open in the + /// browser, until [answerGitHub] or [cancelGitHub]. + bool gitHubPageOpens = false; + + /// How often a GitHub sign-in in progress was cancelled. + int gitHubCancels = 0; + + Completer? _gitHubPage; + + /// The people who sign in with GitHub. + final gitHubPeople = {}; + + /// Whether the instance offers email sign-in. + bool offersEmail = true; + + /// The codes mailed, in order. + final mailed = + <({String address, String language, String request, String code})>[]; + + /// Who signs in with which address (lower case), by the person's id. + final addressOwners = {}; + + /// Thrown by the next email sign-in steps while set. + Exception? emailError; + + /// How often this device switched to the person of an address. + int emailSwitches = 0; + + @override + AuthStatus get status => _status; + + @override + Stream get statusChanges => _changes.stream; + + @override + Future signInAnonymously() async { + signIns++; + if (signInError case final error?) { + throw error; + } + _set(SignedIn(authUserId: 'anonymous-$signIns', isIdentified: false)); + } + + @override + Future signOut() async { + signOuts++; + _set(const SignedOut()); + } + + @override + Future deleteAccount() async { + accountDeletions++; + _set(const SignedOut()); + } + + /// Ends the session as when the server revokes this device's session. + void revokeSession() => _set(const SignedOut()); + + @override + Future gitHubClientId() async => + offersGitHub ? 'github-client-id' : null; + + @override + Future linkGitHub() async { + if (gitHubError case final error?) { + throw error; + } + if (!await _authorize()) { + return GitHubLinkOutcome.cancelled; + } + if (gitHubLinkOutcome == GitHubLinkOutcome.linked) { + final person = _status as SignedIn; + gitHubPeople.add(person.authUserId); + _set(SignedIn(authUserId: person.authUserId, isIdentified: true)); + } + return gitHubLinkOutcome; + } + + @override + Future switchToGitHub() async { + if (gitHubError case final error?) { + throw error; + } + if (!await _authorize()) { + return false; + } + gitHubSwitches++; + gitHubPeople.add('github-person'); + _set(const SignedIn(authUserId: 'github-person', isIdentified: true)); + return true; + } + + @override + Future cancelGitHub() async { + gitHubCancels++; + _gitHubPage?.complete(false); + _gitHubPage = null; + } + + /// GitHub sends the browser back to the app: the sign-in goes on. + void answerGitHub() { + _gitHubPage?.complete(true); + _gitHubPage = null; + } + + /// Whether GitHub's page was answered rather than cancelled. + Future _authorize() => + gitHubPageOpens ? (_gitHubPage = Completer()).future : Future.value(true); + + @override + Future offersEmailSignIn() async => offersEmail; + + @override + Future sendEmailCode( + String address, { + required String language, + }) async { + if (emailError case final error?) { + throw error; + } + final to = address.trim().toLowerCase(); + if (!to.contains('@')) { + throw const EmailSignInFailed(EmailSignInProblem.notAnAddress); + } + final number = mailed.length + 1; + mailed.add(( + address: to, + language: language, + request: 'request-$number', + code: 'CODE-$number', + )); + return 'request-$number'; + } + + @override + Future verifyEmailCode(String request, String code) async { + if (emailError case final error?) { + throw error; + } + final mail = mailed.where((each) => each.request == request).lastOrNull; + if (mail == null) { + throw const EmailSignInFailed(EmailSignInProblem.codeExpired); + } + if (mail.code != code.trim().toUpperCase()) { + throw const EmailSignInFailed(EmailSignInProblem.wrongCode); + } + final person = _status as SignedIn; + switch (addressOwners[mail.address]) { + case final owner? when owner != person.authUserId: + return EmailTakenByAnotherPerson('switch:${mail.address}'); + case _: + addressOwners[mail.address] = person.authUserId; + _set(SignedIn(authUserId: person.authUserId, isIdentified: true)); + return const EmailLinked(); + } + } + + @override + Future switchToEmail(String switchToken) async { + if (emailError case final error?) { + throw error; + } + final owner = addressOwners[switchToken.substring('switch:'.length)]!; + emailSwitches++; + _set(SignedIn(authUserId: owner, isIdentified: true)); + return owner; + } + + @override + Future signInWays() async { + final person = (_status as SignedIn).authUserId; + return SignInWays( + gitHub: gitHubPeople.contains(person), + email: addressOwners.containsValue(person), + ); + } + + void _set(AuthStatus status) { + _status = status; + _changes.add(status); + } +} diff --git a/packages/halkyon_app/lib/src/testing/fake_instance_checker.dart b/packages/halkyon_app/lib/src/testing/fake_instance_checker.dart new file mode 100644 index 0000000..bea134e --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/fake_instance_checker.dart @@ -0,0 +1,25 @@ +import 'package:halkyon_app/src/instance/instance.dart'; +import 'package:halkyon_app/src/instance/instance_checker.dart'; +import 'package:halkyon_app/src/instance/instance_locations.dart'; + +/// Says that the instances in [answering] answer, and no others; those in +/// [locations] say where they keep people's data, the others nothing. +class FakeInstanceChecker([ + final Set answering = const {}, + final Map locations = const {}, +]) implements InstanceChecker { + /// The instances asked, in order. + final asked = []; + + @override + Future answers(Instance instance) async { + asked.add(instance); + return answering.contains(instance); + } + + @override + Future locationsOf(Instance instance) async => + answering.contains(instance) + ? locations[instance] ?? const InstanceLocations() + : null; +} diff --git a/packages/halkyon_app/lib/src/testing/memory_instance_store.dart b/packages/halkyon_app/lib/src/testing/memory_instance_store.dart new file mode 100644 index 0000000..8dd4072 --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/memory_instance_store.dart @@ -0,0 +1,15 @@ +import 'package:halkyon_app/src/instance/instance_store.dart'; + +/// Keeps the instances in memory. +class MemoryInstanceStore([InstanceList? initial]) implements InstanceStore { + var _instances = initial; + + /// What is kept now. + InstanceList? get kept => _instances; + + @override + Future read() async => _instances; + + @override + Future write(InstanceList instances) async => _instances = instances; +} diff --git a/packages/halkyon_app/lib/src/testing/memory_pending_changes_store.dart b/packages/halkyon_app/lib/src/testing/memory_pending_changes_store.dart new file mode 100644 index 0000000..a5b76f5 --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/memory_pending_changes_store.dart @@ -0,0 +1,24 @@ +import 'package:halkyon_app/src/sync/pending_changes_store.dart'; + +/// Keeps the count of changes not on the server yet, and whether a sync +/// went through, in memory. +class MemoryPendingChangesStore([int initial = 0, bool synced = true]) + implements PendingChangesStore { + var _count = initial; + var _synced = synced; + + /// The count kept now. + int get kept => _count; + + /// Whether it keeps that a sync went through. + bool get synced => _synced; + + @override + Future read() async => (pending: _count, synced: _synced); + + @override + Future write(KeptSync kept) async { + _count = kept.pending; + _synced = kept.synced; + } +} diff --git a/packages/halkyon_app/lib/src/testing/memory_sign_in_addresses.dart b/packages/halkyon_app/lib/src/testing/memory_sign_in_addresses.dart new file mode 100644 index 0000000..74ad194 --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/memory_sign_in_addresses.dart @@ -0,0 +1,15 @@ +import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart'; + +/// [SignInAddresses] in memory. +class MemorySignInAddresses() implements SignInAddresses { + /// The addresses remembered, by person. + final addresses = {}; + + @override + Future of(String authUserId) async => addresses[authUserId]; + + @override + Future remember(String authUserId, String address) async { + addresses[authUserId] = address; + } +} diff --git a/packages/halkyon_app/lib/src/testing/memory_signed_out_mark.dart b/packages/halkyon_app/lib/src/testing/memory_signed_out_mark.dart new file mode 100644 index 0000000..d5f2037 --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/memory_signed_out_mark.dart @@ -0,0 +1,14 @@ +import 'package:halkyon_app/src/auth/data/signed_out_mark.dart'; + +/// [SignedOutMark] in memory. +class MemorySignedOutMark([var bool _signedOut = false]) + implements SignedOutMark { + /// Whether the device is marked signed out now. + bool get signedOut => _signedOut; + + @override + Future read() async => _signedOut; + + @override + Future write({required bool signedOut}) async => _signedOut = signedOut; +} diff --git a/packages/halkyon_app/lib/src/testing/test_app.dart b/packages/halkyon_app/lib/src/testing/test_app.dart new file mode 100644 index 0000000..a8092e4 --- /dev/null +++ b/packages/halkyon_app/lib/src/testing/test_app.dart @@ -0,0 +1,135 @@ +import 'dart:typed_data'; + +import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; +import 'package:halkyon_app/src/app/app_frame.dart'; +import 'package:halkyon_app/src/app/app_scope.dart'; +import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; +import 'package:halkyon_app/src/auth/data/auth_repository.dart'; +import 'package:halkyon_app/src/auth/model/auth_status.dart'; +import 'package:halkyon_app/src/testing/fake_auth_gateway.dart'; +import 'package:halkyon_app/src/testing/memory_sign_in_addresses.dart'; +import 'package:halkyon_app/src/testing/memory_signed_out_mark.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A transparent pixel, as an app's icon in tests. +final testIcon = MemoryImage( + Uint8List.fromList(const [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, // + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, + 0x0D, 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, 0x42, 0x60, 0x82, + ]), +); + +/// The texts a test app speaks: this package's, halkyon_ui's and +/// Material's (from material_ui). +const testLocalizationsDelegates = >[ + HalkyonAppLocalizations.delegate, + HalkyonUiLocalizations.delegate, + ...GlobalMaterialLocalizations.delegates, +]; + +/// An app's frame as a test gives it: Halkyon Test, whose people keep +/// notes. +AppFrame testFrame() => AppFrame( + name: (context) => 'Halkyon Test', + product: (context) => 'Test', + icon: testIcon, + exampleAddress: 'test.example.org', + firstStartSubtitle: (context) => 'Choose the instance to keep your notes on.', + switchLeavesBehind: (context) => + "The notes on this device can't be combined with it yet: if you " + 'switch, they are deleted.', + theme: appTheme, + localizationsDelegates: testLocalizationsDelegates, + supportedLocales: HalkyonAppLocalizations.supportedLocales, + launchScreen: LaunchScreen( + icon: testIcon, + product: 'Test', + name: 'Halkyon Test', + ), +); + +/// [home] in an app as a Halkyon app builds one, in [locale], under +/// [frame] ([testFrame] unless given) and, where given, [dependencies]. +Widget testApp( + Widget home, { + AppFrame? frame, + AppDependencies? dependencies, + Locale locale = const Locale('en'), +}) { + final appFrame = frame ?? testFrame(); + final app = MaterialApp( + theme: appTheme(Brightness.light), + locale: locale, + localizationsDelegates: appFrame.localizationsDelegates, + supportedLocales: appFrame.supportedLocales, + home: home, + ); + return AppFrameScope( + frame: appFrame, + child: switch (dependencies) { + final dependencies? => AppScope(dependencies: dependencies, child: app), + null => app, + }, + ); +} + +/// An app's dependencies on an instance as a test gives them: signing in +/// through [gateway] ([FakeAuthGateway] unless given), the device's mark +/// and addresses in memory, and each person's dependencies a [TestPerson]. +class TestAppDependencies({ + /// The instance's name. + @override final String serverName = 'test.example.org', + final AuthGateway? gateway, +}) implements AppDependencies { + /// The gateway signing in goes through. + late final AuthGateway authGateway = gateway ?? FakeAuthGateway(); + + /// The email addresses remembered. + final addresses = MemorySignInAddresses(); + + @override + late final AuthRepository auth = AuthRepository(authGateway, addresses); + + @override + final MemorySignedOutMark signedOut = MemorySignedOutMark(); + + /// The people whose dependencies were opened, in order. + final opened = []; + + /// Thrown by opening a person's dependencies while set. + Exception? openingError; + + /// Whether these were disposed. + bool disposed = false; + + @override + Future openPerson(SignedIn person) async { + if (openingError case final error?) { + throw error; + } + final opening = TestPerson(person); + opened.add(opening); + return opening; + } + + @override + Future dispose() async => disposed = true; +} + +/// A person's dependencies as a test gives them: who they are, and whether +/// they were disposed. +class TestPerson( + /// Whose they are. + final SignedIn person, +) implements Disposable { + /// Whether they were disposed. + bool disposed = false; + + @override + Future dispose() async => disposed = true; +} diff --git a/packages/halkyon_app/lib/testing.dart b/packages/halkyon_app/lib/testing.dart new file mode 100644 index 0000000..93df332 --- /dev/null +++ b/packages/halkyon_app/lib/testing.dart @@ -0,0 +1,12 @@ +/// Fakes of halkyon_app's boundaries, and a frame and dependencies built on +/// them, for an app's tests and this package's own: nothing here reaches a +/// server, the system's secure storage or the file system. +library; + +export 'src/testing/fake_auth_gateway.dart'; +export 'src/testing/fake_instance_checker.dart'; +export 'src/testing/memory_instance_store.dart'; +export 'src/testing/memory_pending_changes_store.dart'; +export 'src/testing/memory_sign_in_addresses.dart'; +export 'src/testing/memory_signed_out_mark.dart'; +export 'src/testing/test_app.dart'; diff --git a/packages/halkyon_app/pubspec.yaml b/packages/halkyon_app/pubspec.yaml new file mode 100644 index 0000000..c00a89d --- /dev/null +++ b/packages/halkyon_app/pubspec.yaml @@ -0,0 +1,62 @@ +name: halkyon_app +description: "What every Halkyon app is built around: the instances a device keeps accounts on, signing in through an instance's halkyon_base, each person's data opened and closed, and syncing it." +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + flutter: ^3.44.4 + +dependencies: + clock: ^1.1.3 + equatable: ^3.0.0 + # The flag of where an instance keeps people's data; it draws lipis' + # flag-icons, whose MIT notice this package registers (assets/flags/). + flag: ^7.1.0 + flutter: + sdk: flutter + flutter_bloc: ^9.1.1 + # gen-l10n's output imports it; apps take Material's texts from material_ui. + flutter_localizations: + sdk: flutter + # LoopbackWebAuth takes the place of the package's own browser half on + # Linux and Windows. + flutter_web_auth_2_platform_interface: ^5.0.0 + halkyon_base_client: + path: ../halkyon_base_client + halkyon_core: + path: ../halkyon_core + halkyon_ui: + path: ../halkyon_ui + # A client of halkyon_base alone can be given a fake one in tests. + http: ^1.6.0 + intl: ^0.20.3 + lucide_icons_flutter: ^3.1.20 + material_ui: ^1.5.0 + path: ^1.9.1 + # Serverpod's packages move together, at one version (as the apps pin them). + serverpod_auth_core_client: 4.0.4 + serverpod_auth_idp_client: 4.0.4 + serverpod_auth_idp_flutter: 4.0.4 + serverpod_client: 4.0.4 + # Its sync refuses an app whose synced tables differ from the server's + # (OfflineSyncTablesHashMismatchException). + serverpod_offline_sync_client: 0.0.10 + url_launcher: ^6.3.2 + window_to_front: ^0.0.4 + +dev_dependencies: + fake_async: ^1.3.3 + flutter_test: + sdk: flutter + # A test drives sign-in through its front, as Serverpod's GitHub sign-in does. + flutter_web_auth_2: ^5.1.0 + +flutter: + # Generates HalkyonAppLocalizations from lib/l10n (see l10n.yaml). + generate: true + + assets: + # flag-icons' MIT notice, for the licence list (registerFlagIconsLicense). + - assets/flags/LICENSE-flag-icons.txt diff --git a/packages/halkyon_app/test/app/instance_gate_test.dart b/packages/halkyon_app/test/app/instance_gate_test.dart new file mode 100644 index 0000000..d4bc13f --- /dev/null +++ b/packages/halkyon_app/test/app/instance_gate_test.dart @@ -0,0 +1,275 @@ +import 'package:flag/flag.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A signed-in person's app as a test makes one: who it is for, and the +/// way to Switch account. +class const _PersonApp(final SignedIn person) extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp( + localizationsDelegates: testLocalizationsDelegates, + supportedLocales: HalkyonAppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + Text('Home of ${person.authUserId}'), + TextButton( + onPressed: () => showAccountSwitcher(context), + child: const Text('Switch account'), + ), + ], + ), + ), + ), + ); +} + +void main() { + final builtIn = Instance.parse('learn.halkyon.eu')!; + final other = Instance.parse('learn.example.org')!; + late MemoryInstanceStore store; + late List opened; + late List dependencies; + late List forgotten; + + setUp(() { + store = MemoryInstanceStore(); + opened = []; + dependencies = []; + forgotten = []; + }); + + /// Starts the app on a device that [usedBefore] the choice existed, with + /// [answering] instances answering a check, saying where they keep data + /// as [locations] has it, and the account on each [signedOut]. + Future start( + WidgetTester tester, { + bool usedBefore = false, + bool signedOut = false, + Set answering = const {}, + Map locations = const {}, + }) async { + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final choice = InstanceChoice( + store, + builtIn: builtIn, + forget: (instance) async => forgotten.add(instance), + ); + await choice.load(usedBefore: usedBefore); + await tester.pumpWidget( + InstanceGate( + frame: testFrame(), + choice: choice, + checker: FakeInstanceChecker(answering, locations), + countryNames: MemoryCountryNames(const { + 'CH': {'en': 'Switzerland'}, + }), + readLook: () async => (themeMode: ThemeMode.light, locale: null), + open: (instance) async { + opened.add(instance); + final opening = TestAppDependencies(serverName: instance.name); + await opening.signedOut.write(signedOut: signedOut); + dependencies.add(opening); + return opening; + }, + app: SessionGate( + look: (context) => (themeMode: ThemeMode.light, locale: null), + app: _PersonApp.new, + ), + ), + ); + await tester.pumpAndSettle(); + return choice; + } + + Future openSwitcher(WidgetTester tester) async { + await tester.tap(find.text('Switch account')); + await tester.pumpAndSettle(); + } + + testWidgets('a new install asks for the instance first, and opens the ' + "build's in one tap", (tester) async { + await start(tester); + + // The wordmark says it to screen readers, naming the app. + expect(find.bySemanticsLabel('Welcome to Halkyon Test'), findsOneWidget); + expect( + find.text('Choose the instance to keep your notes on.'), + findsOneWidget, + reason: "the app's own words", + ); + expect(opened, isEmpty, reason: 'nothing made anywhere yet'); + expect(find.byType(TextField), findsNothing, reason: 'no address asked'); + + await tester.tap(find.text('learn.halkyon.eu')); + await tester.pumpAndSettle(); + + expect(opened, [builtIn]); + expect(find.text('Home of anonymous-1'), findsOneWidget); + }); + + testWidgets("the build's instance shows where it keeps people's data, " + 'once it says', (tester) async { + await start( + tester, + answering: {builtIn}, + locations: { + builtIn: const InstanceLocations(data: 'EU', backups: 'CH'), + }, + ); + + expect(find.text('Data: European Union\nBackups: Switzerland'), findsOne); + expect(find.byType(Flag), findsOne); + }); + + testWidgets('an instance saying nothing, or not answering, comes with the ' + 'app, without a flag', (tester) async { + await start(tester); + + expect(find.text('Comes with the app'), findsOne); + expect(find.byType(Flag), findsNothing); + }); + + testWidgets('another instance is checked before the app moves there', ( + tester, + ) async { + await start(tester, answering: {other}); + + await tester.tap(find.text('Another instance')); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType(), + isNotNull, + reason: 'the address is typed at once', + ); + expect( + find.widgetWithText(TextField, 'test.example.org'), + findsOneWidget, + reason: "the app's example address as the hint", + ); + + await tester.enterText(find.byType(TextField), 'nowhere.example.org'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect( + find.text('No instance of Halkyon Test answers at nowhere.example.org.'), + findsOneWidget, + ); + expect(opened, isEmpty); + + await tester.enterText(find.byType(TextField), 'learn.example.org'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(opened, [other]); + }); + + testWidgets('an install from before goes on without asking', (tester) async { + await start(tester, usedBefore: true); + + expect(opened, [builtIn]); + expect(find.text('Home of anonymous-1'), findsOneWidget); + }); + + testWidgets('Switch account moves between the accounts on the device, ' + 'closing the app on the one before, and removes one not in ' + 'use', (tester) async { + store = MemoryInstanceStore((current: builtIn, known: [builtIn, other])); + await start( + tester, + answering: {builtIn, other}, + locations: { + other: const InstanceLocations( + data: 'CH', + backups: InstanceLocations.noBackups, + ), + }, + ); + await openSwitcher(tester); + expect(find.text('learn.halkyon.eu'), findsWidgets); + // Each account with where its instance says it keeps people's data. + expect(find.text('Data: Switzerland\nNo backups'), findsOne); + expect(find.byType(Flag), findsOne); + expect(find.text('Anonymous'), findsOne, reason: 'the account in use'); + await tester.tap(find.text('learn.example.org')); + await tester.pumpAndSettle(); + expect(opened, [builtIn, other]); + expect(dependencies.first.disposed, isTrue); + expect(find.text('Home of anonymous-1'), findsOneWidget); + + await openSwitcher(tester); + await tester.tap(find.byTooltip('Remove account')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Remove')); + await tester.pumpAndSettle(); + + expect(forgotten, [builtIn]); + expect(store.kept?.known, [other]); + }); + + testWidgets('signed out, the start screen names the instance and offers ' + 'another, through Switch account', (tester) async { + store = MemoryInstanceStore((current: builtIn, known: [builtIn])); + await start(tester, answering: {builtIn, other}, signedOut: true); + + expect(find.text('Start on learn.halkyon.eu:'), findsOneWidget); + await tester.tap(find.text('Use another instance')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add account')); + await tester.pumpAndSettle(); + expect( + find.text('Choose the instance for the account to add.'), + findsOneWidget, + ); + + await tester.enterText(find.byType(TextField), 'learn.example.org'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(opened, [builtIn, other]); + expect(find.text('Start on learn.example.org:'), findsOneWidget); + }); + + testWidgets("an instance whose app doesn't open says so, and tries " + 'again', (tester) async { + store = MemoryInstanceStore((current: builtIn, known: [builtIn])); + final choice = InstanceChoice( + store, + builtIn: builtIn, + forget: (instance) async {}, + ); + await choice.load(usedBefore: false); + var attempts = 0; + await tester.pumpWidget( + InstanceGate( + frame: testFrame(), + choice: choice, + checker: FakeInstanceChecker(), + countryNames: MemoryCountryNames(const {}), + readLook: () async => (themeMode: ThemeMode.light, locale: null), + open: (instance) async { + if (++attempts == 1) { + throw StateError('disk unavailable'); + } + return TestAppDependencies(); + }, + app: const Directionality( + textDirection: TextDirection.ltr, + child: Text('the app'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("Couldn't open the app on learn.halkyon.eu."), findsOne); + + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + + expect(find.text('the app'), findsOne); + }); +} diff --git a/packages/halkyon_app/test/app/person_scope_test.dart b/packages/halkyon_app/test/app/person_scope_test.dart new file mode 100644 index 0000000..634f745 --- /dev/null +++ b/packages/halkyon_app/test/app/person_scope_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; +import 'package:material_ui/material_ui.dart'; + +/// What the person sees: whose dependencies are open above it. +class const _Person() extends StatelessWidget { + @override + Widget build(BuildContext context) => + Text('Data of ${PersonScope.of(context).person.authUserId}'); +} + +void main() { + const ada = SignedIn(authUserId: 'ada', isIdentified: true); + late TestAppDependencies dependencies; + + setUp(() => dependencies = TestAppDependencies()); + + Future show(WidgetTester tester, Widget scope) async { + await tester.pumpWidget(testApp(scope, dependencies: dependencies)); + await tester.pumpAndSettle(); + } + + testWidgets("shows the person's app once their data is open", ( + tester, + ) async { + await show(tester, const PersonScope(person: ada, child: _Person())); + + expect(find.text('Data of ada'), findsOneWidget); + expect(dependencies.opened.single.person, ada); + }); + + testWidgets("says so when the person's data can't be opened, and offers " + 'to try again; an attempt that works shows the app', (tester) async { + dependencies.openingError = Exception('database locked'); + await show(tester, const PersonScope(person: ada, child: _Person())); + + expect(find.text("Couldn't open this device's data."), findsOneWidget); + expect(find.text('Data of ada'), findsNothing); + + dependencies.openingError = null; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + + expect(find.text('Data of ada'), findsOneWidget); + }); + + testWidgets("closes the person's data when it goes away", (tester) async { + await show(tester, const PersonScope(person: ada, child: _Person())); + + await show(tester, const Text('Someone else')); + + expect(dependencies.opened.single.disposed, isTrue); + }); +} diff --git a/packages/halkyon_app/test/auth/bloc/email_sign_in_bloc_test.dart b/packages/halkyon_app/test/auth/bloc/email_sign_in_bloc_test.dart new file mode 100644 index 0000000..534495c --- /dev/null +++ b/packages/halkyon_app/test/auth/bloc/email_sign_in_bloc_test.dart @@ -0,0 +1,242 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false); + + late FakeAuthGateway gateway; + late MemorySignInAddresses addresses; + + /// Whether this device holds content a switch would leave behind. + late bool holdsContent; + + setUp(() { + gateway = FakeAuthGateway(status: anonymous); + addresses = MemorySignInAddresses(); + holdsContent = false; + }); + + EmailSignInBloc newBloc({String language = 'en'}) { + final bloc = EmailSignInBloc( + AuthRepository(gateway, addresses), + deviceHoldsContent: () async => holdsContent, + language: language, + ); + addTearDown(bloc.close); + return bloc; + } + + /// A bloc waiting for the code mailed to [address]. + Future codeMailed(String address) async { + final bloc = newBloc()..add(EmailSignInEventCodeRequested(address)); + await expectLater( + bloc.stream, + emitsThrough(isA()), + ); + return bloc; + } + + test('starts at the address', () { + expect(newBloc().state, const EmailSignInStateAddress()); + }); + + test("mails a code in the app's language, and the code makes the address " + "the person's: they keep everything and become identified", () async { + final bloc = newBloc(language: 'de') + ..add(const EmailSignInEventCodeRequested(' Anna@Example.org ')); + + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateAddress(sending: true), + const EmailSignInStateCode( + address: 'anna@example.org', + request: 'request-1', + ), + ]), + ); + expect(gateway.mailed.single.language, 'de'); + + bloc.add(const EmailSignInEventCodeEntered('code-1')); + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateCode( + address: 'anna@example.org', + request: 'request-1', + checking: true, + ), + const EmailSignInStateLinked(address: 'anna@example.org'), + ]), + ); + expect( + gateway.status, + const SignedIn(authUserId: 'anonymous-1', isIdentified: true), + ); + expect(addresses.addresses, {'anonymous-1': 'anna@example.org'}); + }); + + test('says what is wrong with an address, and with a code, and takes the ' + 'next try', () async { + final bloc = newBloc()..add(const EmailSignInEventCodeRequested('anna')); + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateAddress(sending: true), + const EmailSignInStateAddress( + problem: EmailSignInProblem.notAnAddress, + ), + ]), + ); + + bloc.add(const EmailSignInEventCodeRequested('anna@example.org')); + await expectLater(bloc.stream, emitsThrough(isA())); + bloc.add(const EmailSignInEventCodeEntered('CODE-9')); + await expectLater( + bloc.stream, + emitsThrough( + const EmailSignInStateCode( + address: 'anna@example.org', + request: 'request-1', + problem: EmailSignInProblem.wrongCode, + ), + ), + ); + bloc.add(const EmailSignInEventCodeEntered('CODE-1')); + await expectLater( + bloc.stream, + emitsThrough(const EmailSignInStateLinked(address: 'anna@example.org')), + ); + }); + + test('mails a new code on request, and goes back to the address', () async { + final bloc = await codeMailed('anna@example.org'); + + bloc.add(const EmailSignInEventCodeResent()); + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateCode( + address: 'anna@example.org', + request: 'request-1', + checking: true, + ), + const EmailSignInStateCode( + address: 'anna@example.org', + request: 'request-2', + resent: true, + ), + ]), + ); + + bloc.add(const EmailSignInEventAddressReopened()); + await expectLater( + bloc.stream, + emits(const EmailSignInStateAddress()), + ); + }); + + test("another person's address: a device holding nothing switches to " + 'them straight away', () async { + gateway.addressOwners['ben@example.org'] = 'ben'; + final bloc = await codeMailed('ben@example.org'); + + bloc.add(const EmailSignInEventCodeEntered('CODE-1')); + await expectLater( + bloc.stream, + emitsThrough(const EmailSignInStateSwitching()), + ); + await pumpEventQueue(); + + expect(gateway.emailSwitches, 1); + expect( + gateway.status, + const SignedIn(authUserId: 'ben', isIdentified: true), + ); + expect(addresses.addresses, {'ben': 'ben@example.org'}); + }); + + test('a device holding content asks first: declining changes nothing, ' + 'confirming switches', () async { + gateway.addressOwners['ben@example.org'] = 'ben'; + holdsContent = true; + final bloc = await codeMailed('ben@example.org'); + + bloc.add(const EmailSignInEventCodeEntered('CODE-1')); + await expectLater( + bloc.stream, + emitsThrough( + const EmailSignInStateConfirmSwitch( + address: 'ben@example.org', + switchToken: 'switch:ben@example.org', + ), + ), + ); + bloc.add(const EmailSignInEventSwitchDeclined()); + await expectLater(bloc.stream, emits(const EmailSignInStateAddress())); + expect(gateway.emailSwitches, 0); + expect(gateway.status, anonymous); + + bloc.add(const EmailSignInEventCodeRequested('ben@example.org')); + await expectLater(bloc.stream, emitsThrough(isA())); + bloc.add(const EmailSignInEventCodeEntered('CODE-2')); + await expectLater( + bloc.stream, + emitsThrough(isA()), + ); + bloc.add(const EmailSignInEventSwitchConfirmed()); + await expectLater(bloc.stream, emits(const EmailSignInStateSwitching())); + await pumpEventQueue(); + expect(gateway.emailSwitches, 1); + }); + + test("someone identified whose address is another person's is told so, " + 'never switched: their own account stays theirs', () async { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'carla', isIdentified: true), + )..addressOwners['ben@example.org'] = 'ben'; + final bloc = await codeMailed('ben@example.org'); + + bloc.add(const EmailSignInEventCodeEntered('CODE-1')); + await expectLater( + bloc.stream, + emitsThrough(const EmailSignInStateTaken(address: 'ben@example.org')), + ); + expect(gateway.emailSwitches, 0); + expect( + gateway.status, + const SignedIn(authUserId: 'carla', isIdentified: true), + ); + }); + + test("says when the instance couldn't send the mail", () async { + gateway.emailError = const EmailSignInFailed( + EmailSignInProblem.mailFailed, + ); + final bloc = newBloc() + ..add(const EmailSignInEventCodeRequested('anna@example.org')); + + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateAddress(sending: true), + const EmailSignInStateAddress(problem: EmailSignInProblem.mailFailed), + ]), + ); + }); + + test("says when the instance can't be reached", () async { + gateway.emailError = Exception('offline'); + final bloc = newBloc() + ..add(const EmailSignInEventCodeRequested('anna@example.org')); + + await expectLater( + bloc.stream, + emitsInOrder([ + const EmailSignInStateAddress(sending: true), + const EmailSignInStateAddress(problem: EmailSignInProblem.failed), + ]), + ); + }); +} diff --git a/packages/halkyon_app/test/auth/bloc/github_sign_in_bloc_test.dart b/packages/halkyon_app/test/auth/bloc/github_sign_in_bloc_test.dart new file mode 100644 index 0000000..ba01e57 --- /dev/null +++ b/packages/halkyon_app/test/auth/bloc/github_sign_in_bloc_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false); + + late FakeAuthGateway gateway; + + /// Whether this device holds content a switch would leave behind. + late bool holdsContent; + + setUp(() { + gateway = FakeAuthGateway(status: anonymous); + holdsContent = false; + }); + + /// A bloc as the account dialog starts it, once it knows whether this + /// instance offers GitHub sign-in. + Future ready() async { + final bloc = GitHubSignInBloc( + AuthRepository(gateway, MemorySignInAddresses()), + deviceHoldsContent: () async => holdsContent, + )..add(const GitHubSignInEventStarted()); + addTearDown(bloc.close); + await expectLater(bloc.stream, emits(const GitHubSignInStateReady())); + return bloc; + } + + test('offers nothing where the instance has no GitHub sign-in', () async { + gateway.offersGitHub = false; + final bloc = GitHubSignInBloc( + AuthRepository(gateway, MemorySignInAddresses()), + deviceHoldsContent: () async => holdsContent, + )..add(const GitHubSignInEventStarted()); + addTearDown(bloc.close); + + await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable())); + }); + + test('links the GitHub account to the person', () async { + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const GitHubSignInStateBusy(), + const GitHubSignInStateLinked(), + ]), + ); + expect( + gateway.status, + const SignedIn(authUserId: 'anonymous-1', isIdentified: true), + ); + }); + + test('changes nothing when the person cancels on GitHub', () async { + gateway.gitHubLinkOutcome = GitHubLinkOutcome.cancelled; + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const GitHubSignInStateBusy(), + const GitHubSignInStateReady(), + ]), + ); + expect(gateway.status, anonymous); + }); + + test( + "the person can cancel while GitHub's page is open; nothing changes", + () async { + gateway + ..canCancelGitHub = true + ..gitHubPageOpens = true; + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + await expectLater( + bloc.stream, + emits(const GitHubSignInStateBusy(cancellable: true)), + ); + bloc.add(const GitHubSignInEventCancelled()); + + await expectLater(bloc.stream, emits(const GitHubSignInStateReady())); + expect(gateway.gitHubCancels, 1); + expect(gateway.status, anonymous); + }, + ); + + test( + "offers no cancel where the system's sign-in screen has its own", + () async { + gateway.gitHubPageOpens = true; + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + + await expectLater( + bloc.stream, + emits( + isA().having( + (state) => state.cancellable, + 'cancellable', + isFalse, + ), + ), + ); + }, + ); + + test( + "closing the dialog while GitHub's page is open cancels the sign-in", + () async { + gateway + ..canCancelGitHub = true + ..gitHubPageOpens = true; + final bloc = await ready(); + bloc.add(const GitHubSignInEventRequested()); + await expectLater( + bloc.stream, + emits(const GitHubSignInStateBusy(cancellable: true)), + ); + + await bloc.close(); + + expect(gateway.gitHubCancels, 1); + expect(gateway.status, anonymous); + }, + ); + + test('closing the dialog otherwise cancels nothing', () async { + final bloc = await ready(); + + await bloc.close(); + + expect(gateway.gitHubCancels, 0); + }); + + test("switches right away when the GitHub account is someone else's and " + 'this device holds nothing', () async { + gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson; + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + await expectLater(bloc.stream, emits(const GitHubSignInStateBusy())); + await pumpEventQueue(); + + expect(gateway.gitHubSwitches, 1); + }); + + test("someone identified whose GitHub account is another person's is told " + 'so, never switched: their own account stays theirs', () async { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'carla', isIdentified: true), + )..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson; + final bloc = await ready(); + + bloc.add(const GitHubSignInEventRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const GitHubSignInStateBusy(), + const GitHubSignInStateTaken(), + ]), + ); + expect(gateway.gitHubSwitches, 0); + expect( + gateway.status, + const SignedIn(authUserId: 'carla', isIdentified: true), + ); + }); + + group("when the GitHub account is someone else's and this device holds " + 'content', () { + late GitHubSignInBloc bloc; + + setUp(() async { + gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson; + holdsContent = true; + bloc = (await ready())..add(const GitHubSignInEventRequested()); + await expectLater( + bloc.stream, + emitsInOrder([ + const GitHubSignInStateBusy(), + const GitHubSignInStateConfirmSwitch(), + ]), + ); + }); + + test('asks first; staying anonymous changes nothing', () async { + bloc.add(const GitHubSignInEventSwitchDeclined()); + + await expectLater(bloc.stream, emits(const GitHubSignInStateReady())); + expect(gateway.gitHubSwitches, 0); + expect(gateway.status, anonymous); + }); + + test('switches once confirmed', () async { + bloc.add(const GitHubSignInEventSwitchConfirmed()); + await expectLater(bloc.stream, emits(const GitHubSignInStateBusy())); + await pumpEventQueue(); + + expect(gateway.gitHubSwitches, 1); + }); + + test("cancelling GitHub's page for the switch stays anonymous", () async { + gateway + ..canCancelGitHub = true + ..gitHubPageOpens = true; + + bloc.add(const GitHubSignInEventSwitchConfirmed()); + await expectLater( + bloc.stream, + emits(const GitHubSignInStateBusy(cancellable: true)), + ); + bloc.add(const GitHubSignInEventCancelled()); + + await expectLater(bloc.stream, emits(const GitHubSignInStateReady())); + expect(gateway.gitHubSwitches, 0); + expect(gateway.status, anonymous); + }); + }); + + test('says so when signing in fails', () async { + final bloc = await ready(); + gateway.gitHubError = Exception('No connection'); + + bloc.add(const GitHubSignInEventRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const GitHubSignInStateBusy(), + const GitHubSignInStateReady(failed: true), + ]), + ); + expect(gateway.status, anonymous); + }); +} diff --git a/packages/halkyon_app/test/auth/bloc/session_bloc_test.dart b/packages/halkyon_app/test/auth/bloc/session_bloc_test.dart new file mode 100644 index 0000000..c8b4ede --- /dev/null +++ b/packages/halkyon_app/test/auth/bloc/session_bloc_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + late FakeAuthGateway gateway; + late MemorySignedOutMark mark; + late SessionBloc bloc; + + SessionStateActive anonymous(int number) => SessionStateActive( + SignedIn(authUserId: 'anonymous-$number', isIdentified: false), + ); + + void start({AuthStatus status = const SignedOut(), bool signedOut = false}) { + gateway = FakeAuthGateway(status: status); + mark = MemorySignedOutMark(signedOut); + bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark); + } + + tearDown(() => bloc.close()); + + test('signs in anonymously at a first start, nobody having signed out ' + 'here', () async { + start(); + + bloc.add(const SessionEventStarted()); + + await expectLater( + bloc.stream, + emitsInOrder([const SessionStateLoading(), anonymous(1)]), + ); + expect(gateway.signIns, 1); + }); + + test('keeps a stored session instead of creating a second account', () async { + const stored = SignedIn(authUserId: 'stored', isIdentified: false); + start(status: stored); + + bloc.add(const SessionEventStarted()); + + await expectLater(bloc.stream, emits(const SessionStateActive(stored))); + expect(gateway.signIns, 0); + }); + + test('reports a failed sign-in and signs in when asked again', () async { + start(); + gateway.signInError = Exception('No connection'); + + bloc.add(const SessionEventStarted()); + await expectLater( + bloc.stream, + emitsInOrder([const SessionStateLoading(), const SessionStateFailed()]), + ); + + gateway.signInError = null; + bloc.add(const SessionEventSignInRequested()); + await expectLater( + bloc.stream, + emitsInOrder([const SessionStateLoading(), anonymous(2)]), + ); + }); + + test('after signing out, asks how to start, making no account; the next ' + 'start of the app asks again', () async { + const stored = SignedIn(authUserId: 'stored', isIdentified: false); + start(status: stored); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateActive(stored))); + + bloc.add(const SessionEventSignOutRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const SessionStateLoading(), + const SessionStateSignedOut(), + ]), + ); + // Signing out of an anonymous account deletes it. + expect(gateway.accountDeletions, 1); + expect(gateway.signIns, 0); + expect(mark.signedOut, isTrue); + + await bloc.close(); + bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark) + ..add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + expect(gateway.signIns, 0); + }); + + test('starting without an account from there signs in anonymously, and ' + 'the device is no longer signed out', () async { + start(signedOut: true); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + + bloc.add(const SessionEventStartedAnonymously()); + + await expectLater( + bloc.stream, + emitsInOrder([const SessionStateLoading(), anonymous(1)]), + ); + expect(mark.signedOut, isFalse); + }); + + test('signing in with GitHub from there switches to its person; ' + 'cancelled, it asks again', () async { + start(signedOut: true); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + + bloc.add(const SessionEventGitHubRequested()); + + await expectLater( + bloc.stream, + emitsThrough( + const SessionStateActive( + SignedIn(authUserId: 'github-person', isIdentified: true), + ), + ), + ); + expect(gateway.signIns, 0); + expect(mark.signedOut, isFalse); + }); + + test('a GitHub account nobody here signs in with yet is said so; any ' + 'other refusal fails, and nothing is made either way', () async { + start(signedOut: true); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + + gateway.gitHubError = const GitHubSignInFailed( + GitHubSignInProblem.unknownAccount, + ); + bloc.add(const SessionEventGitHubRequested()); + await expectLater( + bloc.stream, + emitsInOrder([ + const SessionStateSignedOut(busy: true), + const SessionStateSignedOut(problem: SignedOutProblem.noGitHubAccount), + ]), + ); + + gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.refused); + bloc.add(const SessionEventGitHubRequested()); + await expectLater( + bloc.stream, + emitsInOrder([ + const SessionStateSignedOut(busy: true), + const SessionStateSignedOut(problem: SignedOutProblem.failed), + ]), + ); + expect(gateway.signIns, 0); + expect(gateway.gitHubSwitches, 0); + }); + + test('with an email address from there, it starts without an account and ' + 'asks for the address', () async { + start(signedOut: true); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + + bloc.add(const SessionEventEmailRequested()); + + await expectLater( + bloc.stream, + emitsThrough( + const SessionStateActive( + SignedIn(authUserId: 'anonymous-1', isIdentified: false), + signsInWithEmail: true, + ), + ), + ); + }); + + test('a session the server ended asks how to start too', () async { + const stored = SignedIn(authUserId: 'github-1', isIdentified: true); + start(status: stored); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateActive(stored))); + + gateway.revokeSession(); + + await expectLater(bloc.stream, emits(const SessionStateSignedOut())); + expect(gateway.signIns, 0); + }); + + test('deleting the account asks how to start', () async { + const stored = SignedIn(authUserId: 'github-1', isIdentified: true); + start(status: stored); + bloc.add(const SessionEventStarted()); + await expectLater(bloc.stream, emits(const SessionStateActive(stored))); + + bloc.add(const SessionEventDeleteRequested()); + + await expectLater( + bloc.stream, + emitsInOrder([ + const SessionStateLoading(), + const SessionStateSignedOut(), + ]), + ); + expect(gateway.accountDeletions, 1); + expect(gateway.signIns, 0); + }); +} diff --git a/packages/halkyon_app/test/auth/data/auth_repository_test.dart b/packages/halkyon_app/test/auth/data/auth_repository_test.dart new file mode 100644 index 0000000..f2b3c48 --- /dev/null +++ b/packages/halkyon_app/test/auth/data/auth_repository_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + group('AuthRepository.ensureSignedIn', () { + test('signs in anonymously when nobody is signed in', () async { + final gateway = FakeAuthGateway(); + + await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn(); + + expect(gateway.signIns, 1); + expect( + gateway.status, + const SignedIn(authUserId: 'anonymous-1', isIdentified: false), + ); + }); + + test('keeps the person already signed in', () async { + const stored = SignedIn(authUserId: 'stored', isIdentified: false); + final gateway = FakeAuthGateway(status: stored); + + await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn(); + + expect(gateway.signIns, 0); + expect(gateway.status, stored); + }); + }); + + group('AuthRepository.signOut', () { + test( + 'deletes an anonymous account, which nobody could sign into again', + () async { + final gateway = FakeAuthGateway( + status: const SignedIn( + authUserId: 'anonymous-1', + isIdentified: false, + ), + ); + + await AuthRepository(gateway, MemorySignInAddresses()).signOut(); + + expect(gateway.accountDeletions, 1); + expect(gateway.signOuts, 0); + expect(gateway.status, const SignedOut()); + }, + ); + + test('only signs an identified person out on this device', () async { + final gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'github-1', isIdentified: true), + ); + + await AuthRepository(gateway, MemorySignInAddresses()).signOut(); + + expect(gateway.signOuts, 1); + expect(gateway.accountDeletions, 0); + }); + }); + + test('deletes the account of whoever is signed in', () async { + final gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'github-1', isIdentified: true), + ); + + await AuthRepository(gateway, MemorySignInAddresses()).deleteAccount(); + + expect(gateway.accountDeletions, 1); + expect(gateway.status, const SignedOut()); + }); + + group('signing in with an email address', () { + late FakeAuthGateway gateway; + late MemorySignInAddresses addresses; + late AuthRepository auth; + + setUp(() { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false), + ); + addresses = MemorySignInAddresses(); + auth = AuthRepository(gateway, addresses); + }); + + test("a code for a new address makes it the person's, and this device " + 'remembers it for them, to show', () async { + final request = await auth.sendEmailCode( + 'anna@example.org', + language: 'en', + ); + + final outcome = await auth.verifyEmailCode( + request, + 'CODE-1', + address: 'anna@example.org', + ); + + expect(outcome, const EmailLinked()); + expect(auth.isIdentified, isTrue); + expect(await auth.emailAddress(), 'anna@example.org'); + }); + + test("a code for another person's address changes nothing here; " + 'switching remembers the address for the person switched to, not ' + 'the one left', () async { + gateway.addressOwners['ben@example.org'] = 'ben'; + final request = await auth.sendEmailCode( + 'ben@example.org', + language: 'en', + ); + + final outcome = await auth.verifyEmailCode( + request, + 'CODE-1', + address: 'ben@example.org', + ); + expect(outcome, isA()); + expect(auth.isIdentified, isFalse); + expect(addresses.addresses, isEmpty); + + await auth.switchToEmail( + (outcome as EmailTakenByAnotherPerson).switchToken, + address: 'ben@example.org', + ); + + expect( + gateway.status, + const SignedIn(authUserId: 'ben', isIdentified: true), + ); + expect(addresses.addresses, {'ben': 'ben@example.org'}); + expect(await auth.emailAddress(), 'ben@example.org'); + }); + + test('a wrong code remembers nothing', () async { + final request = await auth.sendEmailCode( + 'anna@example.org', + language: 'en', + ); + + await expectLater( + auth.verifyEmailCode(request, 'CODE-9', address: 'anna@example.org'), + throwsA( + isA().having( + (failure) => failure.problem, + 'problem', + EmailSignInProblem.wrongCode, + ), + ), + ); + expect(addresses.addresses, isEmpty); + }); + }); +} diff --git a/packages/halkyon_app/test/auth/data/loopback_web_auth_test.dart b/packages/halkyon_app/test/auth/data/loopback_web_auth_test.dart new file mode 100644 index 0000000..7113dac --- /dev/null +++ b/packages/halkyon_app/test/auth/data/loopback_web_auth_test.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show AppLifecycleState; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app'; + + late int port; + late String callback; + late Completer pageOpened; + late int broughtToFront; + late LoopbackWebAuth webAuth; + + /// The loopback part with a fake browser that only records the page it + /// opens; [opens] is whether opening it works. + LoopbackWebAuth loopback({ + bool opens = true, + Future Function()? bringToFront, + Duration timeout = const Duration(minutes: 1), + }) => LoopbackWebAuth( + appName: 'Halkyon Learn', + launch: (url) { + pageOpened.complete(url); + return Future.value(opens); + }, + bringToFront: + bringToFront ?? + () { + broughtToFront++; + return Future.value(); + }, + timeout: timeout, + ); + + setUp(() async { + // A port that is free right now, so the tests never meet a running app. + final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + port = probe.port; + await probe.close(); + callback = 'http://127.0.0.1:$port/github-callback'; + pageOpened = Completer(); + broughtToFront = 0; + webAuth = loopback(); + }); + tearDown(() => webAuth.cancel()); + + /// Starts a sign-in as flutter_web_auth_2 hands it over. + Future signIn() => webAuth.authenticate( + url: gitHubPage, + callbackUrlScheme: callback, + options: const {}, + ); + + /// The browser asking this machine's [address] for [pathAndQuery], as when + /// GitHub sends it back. Over a raw socket, so that the fake HTTP of + /// Flutter's test binding can't get in between. + Future<({int status, String body})> browserAsks( + String pathAndQuery, { + InternetAddress? address, + }) async { + final socket = await Socket.connect( + address ?? InternetAddress.loopbackIPv4, + port, + timeout: const Duration(seconds: 2), + ); + socket.write( + 'GET $pathAndQuery HTTP/1.1\r\n' + 'Host: 127.0.0.1:$port\r\n' + 'Connection: close\r\n\r\n', + ); + final response = await utf8.decoder.bind(socket).join(); + socket.destroy(); + return ( + status: int.parse(response.split(' ')[1]), + body: response.substring(response.indexOf('\r\n\r\n') + 4), + ); + } + + test( + "opens GitHub's page and returns where the browser comes back to", + () async { + final result = signIn(); + + expect(await pageOpened.future, Uri.parse(gitHubPage)); + final response = await browserAsks('/github-callback?code=c&state=s'); + + expect(await result, '$callback?code=c&state=s'); + expect(response.status, HttpStatus.ok); + expect(response.body, contains('return to Halkyon Learn')); + expect(broughtToFront, 1); + }, + ); + + test( + "keeps waiting when the app's window regains focus", + () async { + // The way the bug went: flutter_web_auth_2's front ends every pending + // sign-in when the app is resumed, and on the desktop the window + // regaining focus resumes it. + TestWidgetsFlutterBinding.ensureInitialized(); + FlutterWebAuth2Platform.instance = webAuth; + final result = FlutterWebAuth2.authenticate( + url: gitHubPage, + callbackUrlScheme: callback, + ); + await pageOpened.future; + + // The browser takes the focus; then the person looks back at the app. + await _embedderReports(AppLifecycleState.inactive); + await _embedderReports(AppLifecycleState.resumed); + final response = await browserAsks('/github-callback?code=c&state=s'); + + expect(response.status, HttpStatus.ok); + expect(await result, '$callback?code=c&state=s'); + }, + // Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback. + skip: !(Platform.isLinux || Platform.isWindows), + ); + + test('a cancel ends the wait as cancelled and stops listening', () async { + final result = signIn(); + await pageOpened.future; + + await webAuth.cancel(); + + await expectLater(result, throwsA(_cancelled)); + await expectLater( + browserAsks('/github-callback?code=c&state=s'), + throwsA(isA()), + ); + }); + + test('gives up after the timeout and stops listening', () async { + webAuth = loopback(timeout: const Duration(milliseconds: 50)); + + await expectLater(signIn(), throwsA(isA())); + await expectLater( + browserAsks('/github-callback?code=c&state=s'), + throwsA(isA()), + ); + }); + + test('a second sign-in ends the first as cancelled', () async { + final first = signIn(); + await pageOpened.future; + pageOpened = Completer(); + + final second = signIn(); + + await expectLater(first, throwsA(_cancelled)); + await pageOpened.future; + await browserAsks('/github-callback?code=c&state=s'); + expect(await second, '$callback?code=c&state=s'); + }); + + test('answers other paths with "not found" and keeps waiting', () async { + final result = signIn(); + await pageOpened.future; + + final stray = await browserAsks('/favicon.ico'); + final response = await browserAsks('/github-callback?code=c&state=s'); + + expect(stray.status, HttpStatus.notFound); + expect(response.status, HttpStatus.ok); + expect(await result, '$callback?code=c&state=s'); + }); + + test('listens on the loopback address only', () async { + final otherAddresses = [ + for (final interface in await NetworkInterface.list()) + for (final address in interface.addresses) + if (!address.isLoopback) address, + ]; + final result = signIn(); + await pageOpened.future; + + for (final address in otherAddresses) { + await expectLater( + browserAsks('/github-callback?code=c&state=s', address: address), + throwsA(isA()), + reason: '$address', + ); + } + await browserAsks('/github-callback?code=c&state=s'); + expect(await result, '$callback?code=c&state=s'); + }); + + test("stops listening when the browser can't be opened", () async { + webAuth = loopback(opens: false); + + await expectLater(signIn(), throwsA(isA())); + await expectLater( + browserAsks('/github-callback?code=c&state=s'), + throwsA(isA()), + ); + }); + + test("signs in even when the window can't come to the front", () async { + webAuth = loopback( + bringToFront: () => Future.error(MissingPluginException()), + ); + final result = signIn(); + await pageOpened.future; + + await browserAsks('/github-callback?code=c&state=s'); + + expect(await result, '$callback?code=c&state=s'); + }); + + test( + 'refuses a callback other than a path on 127.0.0.1 with a port', + () async { + for (final address in [ + 'http://0.0.0.0:$port/github-callback', + 'http://localhost:$port/github-callback', + 'http://example.com:$port/github-callback', + 'https://127.0.0.1:$port/github-callback', + 'http://127.0.0.1/github-callback', + 'http://127.0.0.1:$port', + ]) { + await expectLater( + webAuth.authenticate( + url: gitHubPage, + callbackUrlScheme: address, + options: const {}, + ), + throwsArgumentError, + reason: address, + ); + } + expect(pageOpened.isCompleted, isFalse); + }, + ); +} + +/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the +/// gateway's callers learn of it. +final _cancelled = isA().having( + (error) => error.code, + 'code', + 'CANCELED', +); + +/// The embedder reporting the app's lifecycle, as it does when the window +/// gains or loses focus. +Future _embedderReports(AppLifecycleState state) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + SystemChannels.lifecycle.name, + SystemChannels.lifecycle.codec.encodeMessage(state.toString()), + (_) {}, + ); diff --git a/packages/halkyon_app/test/auth/data/serverpod_auth_gateway_test.dart b/packages/halkyon_app/test/auth/data/serverpod_auth_gateway_test.dart new file mode 100644 index 0000000..2b4a284 --- /dev/null +++ b/packages/halkyon_app/test/auth/data/serverpod_auth_gateway_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_base_client/halkyon_base_client.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +/// A session kept in memory, as no test reaches the system's storage. +final class _MemoryStorage() implements ClientAuthSuccessStorage { + AuthSuccess? _session; + + @override + Future get() async => _session; + + @override + Future set(AuthSuccess? data) async => _session = data; +} + +/// An instance's halkyon_base as the network shows it: anonymous sign-ins +/// get `anonymous-token`, its basics offer GitHub and email, and an address +/// belongs to Ben, whom a switch signs in as with `ben-token`. +final class _Instance() { + static final anonymous = UuidValue.withValidation( + '0191f0c4-1111-7000-8000-000000000001', + ); + static final ben = UuidValue.withValidation( + '0191f0c4-2222-7000-8000-000000000002', + ); + + /// Each call, by its endpoint and method, with the session it carried. + final calls = <({String call, String? session})>[]; + + /// What mailing a code is refused for, while set. + EmailSignInRefusal? sendRefused; + + http.Client client() => MockClient((request) async { + final call = request.url.path.substring(1); + calls.add((call: call, session: request.headers['authorization'])); + String sent(Object? body) => jsonEncode(body); + return switch (call) { + 'halkyon_base.anonymousIdp/login' => http.Response( + sent(_session(anonymous, 'anonymous-token', identified: false)), + 200, + ), + 'halkyon_base.instance/basics' => http.Response( + sent( + InstanceBasics( + app: 'learn', + gitHubClientId: 'github-client', + emailSignIn: true, + sourceCodeUrl: 'https://code.example.org/learn', + minimumAppBuild: 1, + ).toJson(), + ), + 200, + ), + 'halkyon_base.emailCode/sendCode' => switch (sendRefused) { + final reason? => http.Response( + sent({ + 'className': 'halkyon_base.EmailSignInRefusedException', + 'data': EmailSignInRefusedException(reason: reason).toJson(), + }), + 400, + ), + null => http.Response( + sent('0191f0c4-3333-7000-8000-000000000003'), + 200, + ), + }, + 'halkyon_base.emailCode/verifyCode' => http.Response( + sent(EmailCodeAccepted(switchToken: 'switch-to-ben').toJson()), + 200, + ), + 'halkyon_base.emailSignIn/signIn' => http.Response( + sent(_session(ben, 'ben-token', identified: true)), + 200, + ), + 'halkyon_base.account/delete' => http.Response('null', 200), + _ => http.Response('', 404), + }; + }); + + static Map _session( + UuidValue person, + String token, { + required bool identified, + }) => AuthSuccess( + authStrategy: 'session', + token: token, + authUserId: person, + scopeNames: {if (identified) 'identified'}, + ).toJson(); +} + +void main() { + late _Instance instance; + late BaseClient app; + late ServerpodAuthGateway gateway; + + setUp(() async { + instance = _Instance(); + app = + BaseClient( + 'https://learn.example.org/', + httpClientOverride: instance.client(), + ) + ..authSessionManager = FlutterAuthSessionManager( + storage: _MemoryStorage(), + ); + await app.auth.initialize(); + gateway = ServerpodAuthGateway( + app, + base: app.modules, + callbackScheme: 'app.halkyon.test', + appName: 'Halkyon Test', + httpClient: instance.client, + ); + addTearDown(() async { + await gateway.dispose(); + app.close(); + }); + }); + + test( + 'signs in anonymously through halkyon_base, nobody identified', + () async { + await gateway.signInAnonymously(); + + expect( + gateway.status, + SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false), + ); + }, + ); + + test('tells what the instance offers from its basics', () async { + expect(await gateway.gitHubClientId(), 'github-client'); + expect(await gateway.offersEmailSignIn(), isTrue); + }); + + test("the instance's refusals to mail a code arrive in the package's own " + 'words', () async { + await gateway.signInAnonymously(); + instance.sendRefused = EmailSignInRefusal.tooManyCodes; + + await expectLater( + gateway.sendEmailCode('ben@example.org', language: 'en'), + throwsA( + isA().having( + (failure) => failure.problem, + 'problem', + EmailSignInProblem.tooManyCodes, + ), + ), + ); + }); + + test("switching to an address's person signs in without this device's " + 'session, then deletes the anonymous account left behind as ' + 'itself', () async { + await gateway.signInAnonymously(); + final request = await gateway.sendEmailCode( + 'ben@example.org', + language: 'en', + ); + final outcome = await gateway.verifyEmailCode(request, 'K7QXM2PD'); + expect(outcome, const EmailTakenByAnotherPerson('switch-to-ben')); + + expect(await gateway.switchToEmail('switch-to-ben'), '${_Instance.ben}'); + + expect( + gateway.status, + SignedIn(authUserId: '${_Instance.ben}', isIdentified: true), + ); + final signIn = instance.calls.singleWhere( + (call) => call.call == 'halkyon_base.emailSignIn/signIn', + ); + expect(signIn.session, isNull, reason: 'no session goes with it'); + final deletion = instance.calls.singleWhere( + (call) => call.call == 'halkyon_base.account/delete', + ); + expect(deletion.session, contains('anonymous-token')); + }); +} diff --git a/packages/halkyon_app/test/auth/widget/email_sign_in_sheet_test.dart b/packages/halkyon_app/test/auth/widget/email_sign_in_sheet_test.dart new file mode 100644 index 0000000..e5c931e --- /dev/null +++ b/packages/halkyon_app/test/auth/widget/email_sign_in_sheet_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; +import 'package:halkyon_ui/halkyon_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + late FakeAuthGateway gateway; + late TestAppDependencies dependencies; + + /// Whether this device holds content a switch would leave behind. + late bool holdsContent; + + /// What the sheet returned once it closed. + late List returned; + + setUp(() { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false), + ); + dependencies = TestAppDependencies(gateway: gateway); + holdsContent = false; + returned = []; + }); + + /// Opens the sheet, in [locale]. + Future open( + WidgetTester tester, { + Locale locale = const Locale('en'), + }) async { + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + testApp( + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async => returned.add( + await showEmailSignInSheet( + context, + deviceHoldsContent: () async => holdsContent, + ), + ), + child: const Text('Open'), + ), + ), + ), + dependencies: dependencies, + locale: locale, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + /// The sheet's text field. + final inSheet = find.descendant( + of: find.byType(SheetScaffold), + matching: find.byType(TextField), + ); + + /// Types [address] and sends a code to it, then types [code]. A switch to + /// another person shows a spinner until their app replaces the whole + /// subtree, which a test never settles: [switches] pumps frames instead. + Future signInWith( + WidgetTester tester, + String address, { + String code = 'CODE-1', + bool switches = false, + }) async { + await tester.enterText(inSheet, address); + await tester.tap(find.widgetWithText(FilledButton, 'Send code')); + await tester.pumpAndSettle(); + expect( + find.text("We mailed a code to $address. It's valid for 15 minutes."), + findsOneWidget, + ); + await tester.enterText(inSheet, code); + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + if (switches) { + await tester.pump(); + await tester.pump(); + } else { + await tester.pumpAndSettle(); + } + } + + testWidgets('an anonymous person continues with email: the address, then ' + 'the code mailed to it, after which the address is theirs', ( + tester, + ) async { + await open(tester); + expect( + find.textContaining('keeps only a fingerprint of your address'), + findsOneWidget, + ); + + await signInWith(tester, 'anna@example.org'); + + expect(returned, ['anna@example.org']); + expect(gateway.mailed.single.language, 'en'); + expect( + gateway.status, + const SignedIn(authUserId: 'anonymous-1', isIdentified: true), + ); + expect(dependencies.addresses.addresses, { + 'anonymous-1': 'anna@example.org', + }); + }); + + testWidgets('the mail is worded in German for someone who reads the app in ' + 'German', (tester) async { + await open(tester, locale: const Locale('de')); + await tester.enterText(inSheet, 'anna@example.org'); + await tester.tap(find.widgetWithText(FilledButton, 'Code senden')); + await tester.pumpAndSettle(); + + expect(gateway.mailed.single.language, 'de'); + }); + + testWidgets('a wrong code says so, and a new one can be asked for', ( + tester, + ) async { + await open(tester); + + await signInWith(tester, 'anna@example.org', code: 'CODE-9'); + + expect(find.text("That code isn't right."), findsOneWidget); + await tester.tap(find.text('Send a new code')); + await tester.pumpAndSettle(); + expect(find.text('A new code is on its way.'), findsOneWidget); + expect(gateway.mailed, hasLength(2)); + }); + + testWidgets("another person's address switches at once where the device " + 'holds nothing', (tester) async { + gateway.addressOwners['ben@example.org'] = 'ben'; + await open(tester); + + await signInWith(tester, 'ben@example.org', switches: true); + + expect(gateway.emailSwitches, 1); + expect( + gateway.status, + const SignedIn(authUserId: 'ben', isIdentified: true), + ); + }); + + testWidgets("another person's address asks first where the device holds " + "content, in the app's own words: staying changes nothing, switching " + 'switches', (tester) async { + gateway.addressOwners['ben@example.org'] = 'ben'; + holdsContent = true; + await open(tester); + + await signInWith(tester, 'ben@example.org'); + + expect( + find.text('Switch to the account of ben@example.org?'), + findsOneWidget, + ); + expect( + find.text( + "The notes on this device can't be combined with it yet: if you " + 'switch, they are deleted.', + ), + findsOneWidget, + ); + await tester.tap(find.text('Stay anonymous')); + await tester.pumpAndSettle(); + expect(gateway.emailSwitches, 0); + + await signInWith(tester, 'ben@example.org', code: 'CODE-2'); + await tester.tap(find.text('Switch')); + await tester.pump(); + await tester.pump(); + expect(gateway.emailSwitches, 1); + }); + + testWidgets("someone identified whose address is another account's is " + 'told so, and stays who they are', (tester) async { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'github-person', isIdentified: true), + )..addressOwners['ben@example.org'] = 'ben'; + dependencies = TestAppDependencies(gateway: gateway); + await open(tester); + + await signInWith(tester, 'ben@example.org'); + + expect( + find.text( + 'ben@example.org already signs in to another account. To switch to ' + 'it, sign out first.', + ), + findsOneWidget, + ); + expect(gateway.emailSwitches, 0); + expect( + gateway.status, + const SignedIn(authUserId: 'github-person', isIdentified: true), + ); + }); +} diff --git a/packages/halkyon_app/test/auth/widget/session_gate_test.dart b/packages/halkyon_app/test/auth/widget/session_gate_test.dart new file mode 100644 index 0000000..925f601 --- /dev/null +++ b/packages/halkyon_app/test/auth/widget/session_gate_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A signed-in person's app as a test makes one: whose it is, whether +/// they asked to continue with email, and a way to sign out. +class const _PersonApp(final SignedIn person) extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp( + localizationsDelegates: testLocalizationsDelegates, + supportedLocales: HalkyonAppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + Text('App of ${person.authUserId}'), + if (context.watch().state case SessionStateActive( + signsInWithEmail: true, + )) + const Text('Asks for the address'), + TextButton( + onPressed: () => context.read().add( + const SessionEventSignOutRequested(), + ), + child: const Text('Sign out'), + ), + ], + ), + ), + ), + ); +} + +void main() { + late FakeAuthGateway gateway; + late TestAppDependencies dependencies; + + setUp(() { + gateway = FakeAuthGateway(); + dependencies = TestAppDependencies(gateway: gateway); + }); + + Future start(WidgetTester tester) async { + await tester.pumpWidget( + AppFrameScope( + frame: testFrame(), + child: AppScope( + dependencies: dependencies, + child: SessionGate( + look: (context) => (themeMode: ThemeMode.light, locale: null), + app: _PersonApp.new, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows the app once signed in anonymously at a first start', ( + tester, + ) async { + await start(tester); + + expect(find.text('App of anonymous-1'), findsOneWidget); + }); + + testWidgets('offers to try again when signing in fails', (tester) async { + gateway.signInError = Exception('No connection'); + await start(tester); + + expect(find.text("Couldn't reach the server."), findsOneWidget); + + gateway.signInError = null; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(find.text('App of anonymous-2'), findsOneWidget); + }); + + testWidgets('signed out, it asks how to start on the instance, making no ' + 'account; starting without one makes one', (tester) async { + await dependencies.signedOut.write(signedOut: true); + await start(tester); + + expect(find.text('Signed out'), findsOneWidget); + expect(find.text('Start on test.example.org:'), findsOneWidget); + expect(find.text('Sign in with GitHub'), findsOneWidget); + expect(find.text('Continue with email'), findsOneWidget); + expect( + find.text('Use another instance'), + findsNothing, + reason: 'no instances to choose among here', + ); + expect(gateway.signIns, 0); + + await tester.tap(find.text('Start without an account')); + await tester.pumpAndSettle(); + + expect(find.text('App of anonymous-1'), findsOneWidget); + expect(dependencies.signedOut.signedOut, isFalse); + }); + + testWidgets('from there, GitHub signs in its person; a GitHub account ' + 'nobody here has yet is said so', (tester) async { + await dependencies.signedOut.write(signedOut: true); + await start(tester); + + gateway.gitHubError = const GitHubSignInFailed( + GitHubSignInProblem.unknownAccount, + ); + await tester.tap(find.text('Sign in with GitHub')); + await tester.pumpAndSettle(); + expect( + find.text( + 'No account here signs in with this GitHub account yet. Start ' + 'without an account, then add GitHub in Profile.', + ), + findsOneWidget, + ); + + gateway.gitHubError = null; + await tester.tap(find.text('Sign in with GitHub')); + await tester.pumpAndSettle(); + expect(find.text('App of github-person'), findsOneWidget); + expect(gateway.signIns, 0); + }); + + testWidgets('from there, an email address starts without an account and ' + 'asks for the address in the app', (tester) async { + await dependencies.signedOut.write(signedOut: true); + await start(tester); + + await tester.tap(find.text('Continue with email')); + await tester.pumpAndSettle(); + + expect(find.text('App of anonymous-1'), findsOneWidget); + expect(find.text('Asks for the address'), findsOneWidget); + }); + + testWidgets('offers only the ways the instance has', (tester) async { + gateway + ..offersGitHub = false + ..offersEmail = false; + await dependencies.signedOut.write(signedOut: true); + await start(tester); + + expect(find.text('Sign in with GitHub'), findsNothing); + expect(find.text('Continue with email'), findsNothing); + expect(find.text('Start without an account'), findsOneWidget); + }); + + testWidgets('signing out, or the instance ending the session, asks how to ' + 'start', (tester) async { + gateway = FakeAuthGateway( + status: const SignedIn(authUserId: 'ada', isIdentified: true), + ); + dependencies = TestAppDependencies(gateway: gateway); + await start(tester); + expect(find.text('App of ada'), findsOneWidget); + + await tester.tap(find.text('Sign out')); + await tester.pumpAndSettle(); + expect(find.text('Signed out'), findsOneWidget); + expect(dependencies.signedOut.signedOut, isTrue); + + await tester.tap(find.text('Start without an account')); + await tester.pumpAndSettle(); + gateway.revokeSession(); + await tester.pumpAndSettle(); + expect(find.text('Signed out'), findsOneWidget); + }); +} diff --git a/packages/halkyon_app/test/base/base_client_test.dart b/packages/halkyon_app/test/base/base_client_test.dart new file mode 100644 index 0000000..02df066 --- /dev/null +++ b/packages/halkyon_app/test/base/base_client_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_base_client/halkyon_base_client.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// [refusal] as an instance sends it: named by its module. +String sent(SerializableException refusal) => jsonEncode({ + 'className': + 'halkyon_base.${switch (refusal) { + EmailSignInRefusedException() => 'EmailSignInRefusedException', + GitHubSignInRefusedException() => 'GitHubSignInRefusedException', + _ => throw ArgumentError.value(refusal), + }}', + 'data': (refusal as SerializableModel).toJson(), +}); + +void main() { + test("reads the refusals an instance sends under halkyon_base's name, as " + "an app's own generated protocol does", () { + final protocol = BaseProtocol(); + + expect( + protocol.decodeWithType( + sent( + EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode), + ), + ), + isA().having( + (refusal) => refusal.reason, + 'reason', + EmailSignInRefusal.wrongCode, + ), + ); + expect( + protocol.decodeWithType( + sent( + GitHubSignInRefusedException( + reason: GitHubSignInRefusal.unknownAccount, + ), + ), + ), + isA().having( + (refusal) => refusal.reason, + 'reason', + GitHubSignInRefusal.unknownAccount, + ), + ); + }); + + test("calls halkyon_base's endpoints at the instance, and passes its " + 'refusals on as themselves', () async { + final asked = []; + final client = BaseClient( + 'https://learn.example.org/', + httpClientOverride: MockClient((request) async { + asked.add(request.url.toString()); + return switch (request.url.path) { + '/halkyon_base.instance/basics' => http.Response( + jsonEncode( + InstanceBasics( + app: 'learn', + emailSignIn: true, + sourceCodeUrl: 'https://code.example.org/learn', + minimumAppBuild: 21, + dataLocation: 'EU', + ).toJson(), + ), + 200, + ), + '/halkyon_base.emailSignIn/signIn' => http.Response( + sent( + EmailSignInRefusedException( + reason: EmailSignInRefusal.codeExpired, + ), + ), + 400, + ), + _ => http.Response('', 404), + }; + }), + ); + addTearDown(client.close); + + final basics = await client.modules.instance.basics(); + expect(basics.emailSignIn, isTrue); + expect(basics.minimumAppBuild, 21); + expect(basics.dataLocation, 'EU'); + await expectLater( + client.modules.emailSignIn.signIn(switchToken: 'token'), + throwsA( + isA().having( + (refusal) => refusal.reason, + 'reason', + EmailSignInRefusal.codeExpired, + ), + ), + ); + expect(asked, [ + 'https://learn.example.org/halkyon_base.instance/basics', + 'https://learn.example.org/halkyon_base.emailSignIn/signIn', + ]); + }); +} diff --git a/packages/halkyon_app/test/instance/country_names_test.dart b/packages/halkyon_app/test/instance/country_names_test.dart new file mode 100644 index 0000000..b7e0b4b --- /dev/null +++ b/packages/halkyon_app/test/instance/country_names_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + test('a country is named in the language asked for, else in English, and ' + 'one without a name not at all', () async { + final names = MemoryCountryNames({ + 'CH': {'de': 'Schweiz', 'en': 'Switzerland'}, + }); + + expect(await names.nameOf('CH', 'de'), 'Schweiz'); + expect(await names.nameOf('ch', 'de'), 'Schweiz'); + expect(await names.nameOf('CH', 'ja'), 'Switzerland'); + expect(await names.nameOf('XX', 'en'), isNull); + }); +} diff --git a/packages/halkyon_app/test/instance/flag_icons_license_test.dart b/packages/halkyon_app/test/instance/flag_icons_license_test.dart new file mode 100644 index 0000000..36b6628 --- /dev/null +++ b/packages/halkyon_app/test/instance/flag_icons_license_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test("the flags' MIT notice joins the licence list, as their licence " + 'asks', () async { + registerFlagIconsLicense(); + + final notices = [ + await for (final entry in LicenseRegistry.licenses) + if (entry.packages.contains('flag-icons')) + entry.paragraphs.map((paragraph) => paragraph.text).join('\n'), + ]; + expect(notices, hasLength(1)); + expect(notices.single, contains('Copyright (c) 2013 Panayiotis Lipiridis')); + expect( + notices.single, + contains( + 'The above copyright notice and this permission notice shall be ' + 'included', + ), + ); + }); +} diff --git a/packages/halkyon_app/test/instance/instance_checker_test.dart b/packages/halkyon_app/test/instance/instance_checker_test.dart new file mode 100644 index 0000000..6a34bf2 --- /dev/null +++ b/packages/halkyon_app/test/instance/instance_checker_test.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_base_client/halkyon_base_client.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + final halkyon = Instance.parse('learn.example.org')!; + final todo = Instance.parse('todo.example.org')!; + final elsewhere = Instance.parse('www.example.org')!; + + /// Basics as an instance of [app] tells them. + http.Response basicsOf(String app) => http.Response( + jsonEncode( + InstanceBasics( + app: app, + emailSignIn: false, + sourceCodeUrl: 'https://code.example.org/$app', + minimumAppBuild: 1, + dataLocation: 'EU', + backupLocation: 'CH', + ).toJson(), + ), + 200, + ); + + /// A checker of the app `learn` reaching instances through a fake network, + /// where learn.example.org answers as an instance of it, todo.example.org + /// as an instance of another app, and nothing else answers. + ServerpodInstanceChecker checker() => ServerpodInstanceChecker( + app: 'learn', + httpClient: () => MockClient( + (request) async => switch ((request.url.host, request.url.path)) { + ('learn.example.org', '/halkyon_base.instance/basics') => basicsOf( + 'learn', + ), + ('todo.example.org', '/halkyon_base.instance/basics') => basicsOf( + 'todo', + ), + _ => http.Response('Not found', 404), + }, + ), + ); + + test('an instance answers when its halkyon_base tells its basics; any ' + 'other site does not', () async { + expect(await checker().answers(halkyon), isTrue); + expect(await checker().answers(elsewhere), isFalse); + }); + + test("another app's instance does not answer, so its address is refused " + 'before the app moves there', () async { + expect(await checker().answers(todo), isFalse); + expect(await checker().locationsOf(todo), isNull); + }); + + test("says where an instance keeps people's data and its backups, and " + 'nothing for a site that is no instance', () async { + expect( + await checker().locationsOf(halkyon), + const InstanceLocations(data: 'EU', backups: 'CH'), + ); + expect(await checker().locationsOf(elsewhere), isNull); + }); +} diff --git a/packages/halkyon_app/test/instance/instance_choice_test.dart b/packages/halkyon_app/test/instance/instance_choice_test.dart new file mode 100644 index 0000000..bf2c1cc --- /dev/null +++ b/packages/halkyon_app/test/instance/instance_choice_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + final builtIn = Instance.parse('learn.halkyon.eu')!; + final other = Instance.parse('learn.example.org')!; + late MemoryInstanceStore store; + late List forgotten; + + InstanceChoice choice() => InstanceChoice( + store, + builtIn: builtIn, + forget: (instance) async { + forgotten.add(instance); + }, + ); + + setUp(() { + store = MemoryInstanceStore(); + forgotten = []; + }); + + test('a new install has no instance until one is chosen', () async { + final instances = choice(); + await instances.load(usedBefore: false); + + expect(instances.current, isNull); + expect(instances.known, isEmpty); + + await instances.use(other); + expect(instances.current, other); + expect(store.kept?.current, other); + expect(store.kept?.known, [other]); + }); + + test('an install from before there was a choice goes on with the ' + "build's instance", () async { + final instances = choice(); + await instances.load(usedBefore: true); + + expect(instances.current, builtIn); + expect(store.kept?.current, builtIn); + expect(store.kept?.known, [builtIn]); + }); + + test('what was chosen is read back', () async { + store = MemoryInstanceStore((current: other, known: [builtIn, other])); + final instances = choice(); + await instances.load(usedBefore: true); + + expect(instances.current, other); + expect(instances.known, [builtIn, other]); + }); + + test('switching keeps every account; removing one forgets what the ' + 'device holds of it, never of the one in use', () async { + final instances = choice(); + await instances.load(usedBefore: true); + await instances.use(other); + await instances.use(builtIn); + expect(instances.known, [builtIn, other]); + + await instances.remove(builtIn); + expect(forgotten, isEmpty, reason: 'in use'); + + await instances.remove(other); + expect(forgotten, [other]); + expect(instances.known, [builtIn]); + expect(store.kept?.current, builtIn); + expect(store.kept?.known, [builtIn]); + }); + + test('tells its listeners of every change', () async { + final instances = choice(); + var changes = 0; + instances.addListener(() => changes++); + + await instances.load(usedBefore: false); + await instances.use(other); + await instances.use(builtIn); + await instances.remove(other); + + expect(changes, 4); + }); +} diff --git a/packages/halkyon_app/test/instance/instance_test.dart b/packages/halkyon_app/test/instance/instance_test.dart new file mode 100644 index 0000000..b6444d6 --- /dev/null +++ b/packages/halkyon_app/test/instance/instance_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + test('reads an address as people type it, HTTPS unless they say not', () { + for (final address in [ + 'learn.example.org', + 'https://learn.example.org', + ' HTTPS://Learn.Example.org/ ', + ]) { + expect( + Instance.parse(address)?.url, + Uri.parse('https://learn.example.org/'), + reason: address, + ); + } + expect( + Instance.parse('http://localhost:8080')?.url, + Uri.parse('http://localhost:8080/'), + ); + expect( + Instance.parse('example.org/learn?x=1#top')?.url, + Uri.parse('https://example.org/learn/'), + ); + }); + + test('refuses what is no address', () { + for (final address in [ + '', + ' ', + 'learn example org', + 'ftp://example.org', + 'https://', + 'https://someone@example.org', + ]) { + expect(Instance.parse(address), isNull, reason: address); + } + }); + + test('is named by its host, and its port if it has one', () { + expect(Instance.parse('learn.halkyon.eu')!.name, 'learn.halkyon.eu'); + expect(Instance.parse('localhost:8080')!.name, 'localhost:8080'); + }); + + test('is the same instance however its address was written', () { + expect( + Instance.parse('learn.example.org'), + Instance.parse('https://LEARN.example.org/'), + ); + }); +} diff --git a/packages/halkyon_app/test/instance/widget/instance_location_words_test.dart b/packages/halkyon_app/test/instance/widget/instance_location_words_test.dart new file mode 100644 index 0000000..9c4c487 --- /dev/null +++ b/packages/halkyon_app/test/instance/widget/instance_location_words_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations_de.dart'; +import 'package:halkyon_app/l10n/halkyon_app_localizations_en.dart'; + +void main() { + final en = HalkyonAppLocalizationsEn(); + final de = HalkyonAppLocalizationsDe(); + const names = {'CH': 'Switzerland', 'DE': 'Germany'}; + String? words(InstanceLocations locations) => + instanceLocationWords(en, locations, names); + + test('data and backups in one place are named once', () { + expect( + words(const InstanceLocations(data: 'EU', backups: 'EU')), + 'Data and backups: European Union', + ); + }); + + test('data and backups apart are named a line each', () { + expect( + words(const InstanceLocations(data: 'EU', backups: 'CH')), + 'Data: European Union\nBackups: Switzerland', + ); + }); + + test('an instance keeping no backups says so', () { + expect( + words( + const InstanceLocations( + data: 'CH', + backups: InstanceLocations.noBackups, + ), + ), + 'Data: Switzerland\nNo backups', + ); + }); + + test('what the instance leaves unsaid is left out', () { + expect(words(const InstanceLocations(data: 'DE')), 'Data: Germany'); + expect( + words(const InstanceLocations(backups: 'CH')), + 'Backups: Switzerland', + ); + expect(words(const InstanceLocations()), isNull); + }); + + test('a country without a name here goes by its code', () { + expect(words(const InstanceLocations(data: 'XK')), 'Data: XK'); + }); + + test('in German too, without the articles some countries take', () { + expect( + instanceLocationWords( + de, + const InstanceLocations(data: 'EU', backups: 'CH'), + const {'CH': 'Schweiz'}, + ), + 'Daten: Europäische Union\nSicherungen: Schweiz', + ); + }); +} diff --git a/packages/halkyon_app/test/instance/widget/instance_tile_test.dart b/packages/halkyon_app/test/instance/widget/instance_tile_test.dart new file mode 100644 index 0000000..48383f8 --- /dev/null +++ b/packages/halkyon_app/test/instance/widget/instance_tile_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + late List tapped; + + setUp(() => tapped = []); + + /// Shows a tile beside something else to move the pointer or focus to. + Future show(WidgetTester tester, {bool? expanded}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + TextButton(onPressed: () {}, child: const Text('Elsewhere')), + InstanceTile( + icon: const Icon(LucideIcons.globe), + label: 'learn.halkyon.eu', + tagline: 'Comes with the app', + expanded: expanded, + onTap: () => tapped.add('tile'), + ), + ], + ), + ), + ), + ); + } + + double ring(WidgetTester tester) => + tester.widget(find.byKey(InstanceTile.ringKey)).opacity; + + double tagline(WidgetTester tester) => tester + .widget( + find.ancestor( + of: find.text('Comes with the app'), + matching: find.byType(Opacity), + ), + ) + .opacity; + + testWidgets('without a mouse, the tagline shows from the start', ( + tester, + ) async { + await show(tester); + + expect(tagline(tester), 1); + expect(ring(tester), 0); + }); + + testWidgets('with a mouse, the ring and the tagline come in under the ' + 'pointer and go once it leaves', (tester) async { + await show(tester); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: tester.getCenter(find.text('Elsewhere'))); + await tester.pumpAndSettle(); + expect(tagline(tester), 0); + expect(ring(tester), 0); + + await mouse.moveTo(tester.getCenter(find.byType(Icon))); + // The frame that starts it, then halfway. + await tester.pump(); + await tester.pump(InstanceTile.duration ~/ 2); + expect(ring(tester), inExclusiveRange(0, 1), reason: 'it fades in'); + await tester.pumpAndSettle(); + expect(tagline(tester), 1); + expect(ring(tester), 1); + + await mouse.moveTo(tester.getCenter(find.text('Elsewhere'))); + await tester.pumpAndSettle(); + expect(tagline(tester), 0); + expect(ring(tester), 0); + }); + + testWidgets("the keyboard's focus rings it, and Enter chooses it", ( + tester, + ) async { + await show(tester); + + // From Elsewhere to the tile. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(ring(tester), 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(tapped, ['tile']); + }); + + testWidgets('it rings while pressed', (tester) async { + await show(tester); + + final finger = await tester.startGesture( + tester.getCenter(find.byType(Icon)), + ); + await tester.pumpAndSettle(); + expect(ring(tester), 1); + + await finger.up(); + await tester.pumpAndSettle(); + expect(ring(tester), 0); + expect(tapped, ['tile']); + }); + + testWidgets('it stays ringed while what it opens is open', (tester) async { + await show(tester, expanded: true); + expect(ring(tester), 1); + + await show(tester, expanded: false); + await tester.pumpAndSettle(); + expect(ring(tester), 0); + }); + + testWidgets('a screen reader hears one button, its tagline included', ( + tester, + ) async { + await show(tester, expanded: false); + + expect( + // The label's node is merged into the tile's. + tester.getSemantics(find.text('learn.halkyon.eu')), + matchesSemantics( + label: 'learn.halkyon.eu\nComes with the app', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + hasExpandedState: true, + ), + ); + }); + + testWidgets('with animations off, it rings at once', (tester) async { + await tester.pumpWidget( + MaterialApp( + // The app's own MediaQuery would hide one above it. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + home: Scaffold( + body: InstanceTile( + icon: const Icon(LucideIcons.globe), + label: 'learn.halkyon.eu', + tagline: 'Comes with the app', + expanded: false, + onTap: () {}, + ), + ), + ), + ); + + await tester.startGesture(tester.getCenter(find.byType(Icon))); + await tester.pump(); + expect(ring(tester), 1); + }); +} diff --git a/packages/halkyon_app/test/l10n/arb_files_test.dart b/packages/halkyon_app/test/l10n/arb_files_test.dart new file mode 100644 index 0000000..8fc8c38 --- /dev/null +++ b/packages/halkyon_app/test/l10n/arb_files_test.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The messages of the `.arb` file [name] in `lib/l10n/`, without metadata. +Map _messages(String name) => { + for (final MapEntry(:key, :value) in (jsonDecode( + File('lib/l10n/$name').readAsStringSync(), + ) as Map).entries) + if (!key.startsWith('@')) key: value! as String, +}; + +/// The names of the placeholders in [message], with plural and select +/// arguments; the words of their branches don't count. (gen-l10n reads +/// quotes as text here: `use-escaping` is off.) +Set _placeholders(String message) { + final names = {}; + // Even depths hold text and placeholders, odd ones a plural's branches. + var depth = 0; + for (var i = 0; i < message.length; i++) { + switch (message[i]) { + case '{': + final name = RegExp(r'\{\s*(\w+)\s*[,}]').matchAsPrefix(message, i); + if (name != null && depth.isEven) { + names.add(name.group(1)!); + } + depth++; + case '}': + depth--; + } + } + return names; +} + +void main() { + final template = _messages('halkyon_app_en.arb'); + + test('German has every message, with the same placeholders', () { + final german = _messages('halkyon_app_de.arb'); + + expect( + template.keys.where((key) => !german.containsKey(key)), + isEmpty, + reason: 'untranslated', + ); + expect( + german.keys.where((key) => !template.containsKey(key)), + isEmpty, + reason: 'not in the template', + ); + for (final MapEntry(:key, :value) in german.entries) { + expect(_placeholders(value), _placeholders(template[key]!), reason: key); + } + }); + + test("the English variants override the template's messages only, with " + 'the same placeholders', () { + for (final variant in ['halkyon_app_en_GB.arb', 'halkyon_app_en_US.arb']) { + for (final MapEntry(:key, :value) in _messages(variant).entries) { + expect(template, contains(key), reason: '$variant: $key'); + expect( + _placeholders(value), + _placeholders(template[key]!), + reason: '$variant: $key', + ); + } + } + }); +} diff --git a/packages/halkyon_app/test/l10n/halkyon_app_localizations_test.dart b/packages/halkyon_app/test/l10n/halkyon_app_localizations_test.dart new file mode 100644 index 0000000..22fcb99 --- /dev/null +++ b/packages/halkyon_app/test/l10n/halkyon_app_localizations_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + test('speaks British English, American English and German', () { + expect( + HalkyonAppLocalizations.supportedLocales, + containsAll(const [ + Locale('en'), + Locale('en', 'GB'), + Locale('en', 'US'), + Locale('de'), + ]), + ); + }); + + test("names the app it is part of where its texts do, as Halkyon Learn's " + 'own texts did', () { + final english = lookupHalkyonAppLocalizations(const Locale('en')); + final german = lookupHalkyonAppLocalizations(const Locale('de')); + + expect( + english.instanceWelcome('Halkyon Learn'), + 'Welcome to Halkyon Learn', + ); + expect( + german.instanceWelcome('Halkyon Learn'), + 'Willkommen bei Halkyon Learn', + ); + expect( + english.instanceUnreachable('Halkyon Learn', 'learn.example.org'), + 'No instance of Halkyon Learn answers at learn.example.org.', + ); + expect( + german.instanceUnreachable('Halkyon Learn', 'learn.example.org'), + 'Unter learn.example.org antwortet keine Instanz von Halkyon Learn.', + ); + }); +} diff --git a/packages/halkyon_app/test/person/person_folders_test.dart b/packages/halkyon_app/test/person/person_folders_test.dart new file mode 100644 index 0000000..767b646 --- /dev/null +++ b/packages/halkyon_app/test/person/person_folders_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + late Directory root; + late PersonFolders folders; + + setUp(() async { + root = await Directory.systemTemp.createTemp('people_'); + folders = PersonFolders(root); + }); + tearDown(() => root.delete(recursive: true)); + + Future write(String person) async { + final folder = await Directory('${root.path}/$person').create(); + await File('${folder.path}/sync.sqlite').writeAsString(person); + } + + Future> people() async => [ + for (final entry in root.listSync()) + entry.uri.pathSegments.lastWhere((segment) => segment.isNotEmpty), + ]..sort(); + + test("opening a person's folder wipes everyone else's", () async { + await write('anonymous-1'); + await write('github-1'); + + final folder = await folders.openOnly('github-1'); + + expect(await people(), ['github-1']); + expect( + await File('${folder.path}/sync.sqlite').readAsString(), + 'github-1', + reason: "the person's own data stays", + ); + }); + + test('creates the folder of a person new to this device', () async { + await write('anonymous-1'); + + final folder = await folders.openOnly('anonymous-2'); + + expect(folder.existsSync(), isTrue); + expect(await people(), ['anonymous-2']); + }); +} diff --git a/packages/halkyon_app/test/serial_queue_in_widget_tests_test.dart b/packages/halkyon_app/test/serial_queue_in_widget_tests_test.dart new file mode 100644 index 0000000..49ad034 --- /dev/null +++ b/packages/halkyon_app/test/serial_queue_in_widget_tests_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_core/halkyon_core.dart'; + +void main() { + late SerialQueue queue; + + setUp(() => queue = SerialQueue()); + + testWidgets("halkyon_core's SerialQueue works under a widget test when " + 'made outside it, as the stores here are', (tester) async { + // Made in the real zone, as a store in setUp is. + expect(await queue.run(() async => 'done'), 'done'); + }); +} diff --git a/packages/halkyon_app/test/sync/app_compatibility_test.dart b/packages/halkyon_app/test/sync/app_compatibility_test.dart new file mode 100644 index 0000000..efb7728 --- /dev/null +++ b/packages/halkyon_app/test/sync/app_compatibility_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:serverpod_offline_sync_client/offline_sync.dart'; + +void main() { + group('AppCompatibility', () { + late int asked; + late Object? answer; + + AppCompatibility compatibility(int? appBuild) => AppCompatibility( + appBuild: appBuild, + minimumAppBuild: () { + asked++; + return switch (answer) { + final Exception error => Future.error(error), + _ => Future.value(answer as int?), + }; + }, + ); + + setUp(() { + asked = 0; + answer = null; + }); + + test('lets any build sync when the server names no minimum', () async { + await compatibility(1).check(); + }); + + test('lets a build at or above the minimum sync', () async { + answer = 3; + await compatibility(3).check(); + await compatibility(4).check(); + }); + + test('refuses a build below the minimum', () async { + answer = 3; + await expectLater( + compatibility(2).check(), + throwsA(isA()), + ); + }); + + test('asks the server once per launch', () async { + answer = 1; + final check = compatibility(1); + await check.check(); + await check.check(); + expect(asked, 1); + }); + + test('asks again after the question failed', () async { + answer = Exception('No connection'); + final check = compatibility(1); + await expectLater(check.check(), throwsException); + + answer = 1; + await check.check(); + expect(asked, 2); + }); + + test('never refuses a build without a number, and never asks', () async { + answer = 99; + await compatibility(null).check(); + expect(asked, 0); + }); + }); + + group('compatibleSync', () { + test('checks before syncing', () async { + var synced = false; + final sync = compatibleSync( + ({required onMerge}) { + synced = true; + return Future.value(); + }, + AppCompatibility(appBuild: 1, minimumAppBuild: () => Future.value(2)), + ); + + await expectLater( + sync(onMerge: () {}), + throwsA(isA()), + ); + expect(synced, isFalse); + }); + + test("tells when the synced tables don't match the server's", () async { + final sync = compatibleSync( + ({required onMerge}) => Future.error( + const OfflineSyncTablesHashMismatchException( + received: 'server', + expected: 'app', + ), + ), + AppCompatibility( + appBuild: 1, + minimumAppBuild: Future.value, + ), + ); + + await expectLater( + sync(onMerge: () {}), + throwsA(isA()), + ); + }); + + test('passes other failures on as they are', () async { + final failure = Exception('No connection'); + final sync = compatibleSync( + ({required onMerge}) => Future.error(failure), + AppCompatibility( + appBuild: 1, + minimumAppBuild: Future.value, + ), + ); + + await expectLater(sync(onMerge: () {}), throwsA(same(failure))); + }); + }); +} diff --git a/packages/halkyon_app/test/sync/pending_changes_store_test.dart b/packages/halkyon_app/test/sync/pending_changes_store_test.dart new file mode 100644 index 0000000..650a80b --- /dev/null +++ b/packages/halkyon_app/test/sync/pending_changes_store_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; + +void main() { + late Directory folder; + late File file; + + setUp(() { + folder = Directory.systemTemp.createTempSync('sync_pending'); + file = File('${folder.path}/sync_pending.json'); + }); + tearDown(() => folder.deleteSync(recursive: true)); + + test('nothing kept yet: none pending, never synced here', () async { + expect(await FilePendingChangesStore(file).read(), ( + pending: 0, + synced: false, + )); + }); + + test('keeps the count and whether a sync went through', () async { + await FilePendingChangesStore(file).write((pending: 3, synced: true)); + + expect(await FilePendingChangesStore(file).read(), ( + pending: 3, + synced: true, + )); + }); + + test('a file from before it kept that is a device that synced', () async { + file.writeAsStringSync('{"pending": 2}'); + + expect(await FilePendingChangesStore(file).read(), ( + pending: 2, + synced: true, + )); + }); +} diff --git a/packages/halkyon_app/test/sync/sync_service_test.dart b/packages/halkyon_app/test/sync/sync_service_test.dart new file mode 100644 index 0000000..c76f816 --- /dev/null +++ b/packages/halkyon_app/test/sync/sync_service_test.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late int syncs; + late Exception? failWith; + late VoidCallback? merge; + // While set and not completed, a sync keeps running. + late Completer? hold; + + SyncService service({ + PendingChangesStore? pendingChanges, + bool syncedBefore = false, + }) => SyncService( + ({required onMerge}) async { + syncs++; + merge = onMerge; + await hold?.future; + if (failWith case final error?) { + throw error; + } + }, + pendingChanges: pendingChanges, + syncedBefore: syncedBefore, + ); + + setUp(() { + syncs = 0; + failWith = null; + merge = null; + hold = null; + }); + + test('syncs once at start', () { + fakeAsync((async) { + final sync = service()..start(); + async.flushMicrotasks(); + + expect(syncs, 1); + sync.dispose(); + }); + }); + + test('syncs once after a burst of local changes has settled', () { + fakeAsync((async) { + final sync = service()..start(); + async.flushMicrotasks(); + + sync + ..requestSync() + ..requestSync(); + async.elapse(const Duration(seconds: 1)); + sync.requestSync(); + async.elapse(const Duration(seconds: 1)); + expect(syncs, 1, reason: 'still within the debounce'); + + async.elapse(const Duration(seconds: 1)); + expect(syncs, 2); + sync.dispose(); + }); + }); + + test('syncs every five minutes without other triggers', () { + fakeAsync((async) { + final sync = service()..start(); + async.elapse(const Duration(minutes: 10)); + + expect(syncs, 3); + sync.dispose(); + }); + }); + + test('keeps syncing after a failed sync', () { + fakeAsync((async) { + failWith = Exception('No connection'); + final sync = service()..start(); + async.flushMicrotasks(); + + failWith = null; + sync.requestSync(); + async.elapse(const Duration(seconds: 2)); + + expect(syncs, 2); + sync.dispose(); + }); + }); + + test('reports changes from other devices', () { + fakeAsync((async) { + final sync = service(); + var merges = 0; + sync.merges.listen((_) => merges++); + sync.start(); + async.flushMicrotasks(); + + merge!(); + async.flushMicrotasks(); + + expect(merges, 1); + sync.dispose(); + }); + }); + + test('a trigger during a sync runs one more sync afterwards', () { + fakeAsync((async) { + final running = hold = Completer(); + final sync = service()..start(); + async.flushMicrotasks(); + + sync.requestSync(); + async.elapse(const Duration(seconds: 2)); + sync.requestSync(); + async.elapse(const Duration(seconds: 2)); + expect(syncs, 1, reason: 'the first sync is still running'); + + running.complete(); + async.flushMicrotasks(); + expect(syncs, 2); + sync.dispose(); + }); + }); + + test('lets a running sync finish before it stops', () { + fakeAsync((async) { + final running = hold = Completer(); + final sync = service()..start(); + async.flushMicrotasks(); + sync.requestSync(); + async.elapse(const Duration(seconds: 2)); + + var stopped = false; + unawaited(sync.dispose().then((_) => stopped = true)); + async.flushMicrotasks(); + expect(stopped, isFalse, reason: 'the database is still in use'); + + running.complete(); + async.flushMicrotasks(); + expect(stopped, isTrue); + expect(syncs, 1, reason: 'no sync starts once it is stopping'); + }); + }); + + test('stops syncing when disposed', () { + fakeAsync((async) { + final sync = service()..start(); + async.flushMicrotasks(); + sync + ..requestSync() + ..dispose(); + + async.elapse(const Duration(minutes: 10)); + expect(syncs, 1); + }); + }); + + group('status', () { + final noon = DateTime.utc(2026, 10, 3, 12); + + test('nothing has synced before the first sync', () { + expect(service().status, const SyncStatus()); + expect(service().status.hasTrouble, isFalse); + }); + + test('says while a sync runs, and when it went through', () { + fakeAsync((async) { + withClock(async.getClock(noon), () { + final running = hold = Completer(); + final sync = service()..start(); + async.flushMicrotasks(); + expect(sync.status, const SyncStatus(syncing: true)); + + async.elapse(const Duration(seconds: 3)); + running.complete(); + async.flushMicrotasks(); + expect( + sync.status, + SyncStatus( + lastSyncedAt: noon.add(const Duration(seconds: 3)), + syncedHere: true, + ), + ); + sync.dispose(); + }); + }); + }); + + test('a failed sync is trouble until a sync goes through again', () { + fakeAsync((async) { + withClock(async.getClock(noon), () { + final sync = service()..start(); + async.flushMicrotasks(); + + failWith = Exception('No connection'); + async.elapse(const Duration(minutes: 5)); + expect( + sync.status, + SyncStatus( + lastSyncedAt: noon, + failure: ( + at: noon.add(const Duration(minutes: 5)), + appMismatch: false, + ), + syncedHere: true, + ), + ); + expect(sync.status.hasTrouble, isTrue); + + failWith = null; + async.elapse(const Duration(minutes: 5)); + expect( + sync.status, + SyncStatus( + lastSyncedAt: noon.add(const Duration(minutes: 10)), + syncedHere: true, + ), + ); + sync.dispose(); + }); + }); + }); + + test('still shows the failure while the next sync runs', () { + fakeAsync((async) { + withClock(async.getClock(noon), () { + failWith = Exception('No connection'); + final sync = service()..start(); + async.flushMicrotasks(); + + hold = Completer(); + sync.syncNow(); + async.flushMicrotasks(); + expect(sync.status.syncing, isTrue); + expect(sync.status.hasTrouble, isTrue); + hold!.complete(); + sync.dispose(); + }); + }); + }); + + test("tells when this app and the server don't match", () { + fakeAsync((async) { + withClock(async.getClock(noon), () { + failWith = const AppServerMismatchException(); + final sync = service()..start(); + async.flushMicrotasks(); + + expect(sync.status.appMismatch, isTrue); + expect(sync.status.failure, (at: noon, appMismatch: true)); + sync.dispose(); + }); + }); + }); + + test('reports every change', () { + fakeAsync((async) { + final sync = service(); + final seen = []; + sync.statusChanges.listen(seen.add); + sync.start(); + async.flushMicrotasks(); + + expect(seen.map((status) => status.syncing), [true, false]); + sync.dispose(); + }); + }); + }); + + test('syncs at once when the person asks', () { + fakeAsync((async) { + final sync = service()..start(); + async.flushMicrotasks(); + + sync.syncNow(); + async.flushMicrotasks(); + + expect(syncs, 2, reason: 'no waiting for changes to settle'); + sync.dispose(); + }); + }); + + group('changes not on the server yet', () { + test('are the saves since the last sync that went through', () { + fakeAsync((async) { + final sync = service()..start(); + async.flushMicrotasks(); + + sync + ..requestSync() + ..requestSync(); + expect(sync.status.pending, 2); + + async.elapse(const Duration(seconds: 2)); + expect(sync.status.pending, 0); + sync.dispose(); + }); + }); + + test('stay while syncs fail', () { + fakeAsync((async) { + failWith = Exception('No connection'); + final sync = service()..start(); + async.flushMicrotasks(); + + sync.requestSync(); + async.elapse(const Duration(seconds: 2)); + expect(sync.status.pending, 1); + expect(sync.status.hasTrouble, isTrue); + + failWith = null; + sync.syncNow(); + async.flushMicrotasks(); + expect(sync.status.pending, 0); + sync.dispose(); + }); + }); + + test('saved while a sync runs, wait for the next', () { + fakeAsync((async) { + final running = hold = Completer(); + final sync = service()..start(); + async.flushMicrotasks(); + + sync.requestSync(); + running.complete(); + async.flushMicrotasks(); + expect(sync.status.pending, 1); + + hold = null; + async.elapse(const Duration(seconds: 2)); + expect(sync.status.pending, 0); + sync.dispose(); + }); + }); + + test('are kept while the app is closed, and taken by the first sync', () { + fakeAsync((async) { + failWith = Exception('No connection'); + final kept = MemoryPendingChangesStore(3); + final sync = service(pendingChanges: kept)..start(); + async.flushMicrotasks(); + expect(sync.status.pending, 3); + + sync.requestSync(); + async.flushMicrotasks(); + expect(kept.kept, 4); + + failWith = null; + sync.syncNow(); + async.flushMicrotasks(); + expect(sync.status.pending, 0); + expect(kept.kept, 0); + sync.dispose(); + }); + }); + }); + + test('says whether a sync ever went through on this device: as kept from ' + 'before, and from the first that goes through, kept for next ' + 'time', () { + fakeAsync((async) { + expect(service(syncedBefore: true).status.syncedHere, isTrue); + + failWith = Exception('No connection'); + final kept = MemoryPendingChangesStore(0, false); + final sync = service(pendingChanges: kept)..start(); + async.flushMicrotasks(); + expect(sync.status.syncedHere, isFalse); + expect(sync.status.hasTrouble, isTrue); + + failWith = null; + sync.syncNow(); + async.flushMicrotasks(); + expect(sync.status.syncedHere, isTrue); + expect(kept.synced, isTrue); + + sync.requestSync(); + async.flushMicrotasks(); + expect(sync.status.syncedHere, isTrue); + sync.dispose(); + }); + }); +} diff --git a/packages/halkyon_app/test/sync/widget/sync_widgets_test.dart b/packages/halkyon_app/test/sync/widget/sync_widgets_test.dart new file mode 100644 index 0000000..d4c0861 --- /dev/null +++ b/packages/halkyon_app/test/sync/widget/sync_widgets_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:halkyon_app/halkyon_app.dart'; +import 'package:halkyon_app/testing.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Where an app puts the sync's widgets: a banner on its home, a dot on its +/// way to Settings, and the Sync card in Settings, which More leads to. +class const _Home() extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + AppMismatchBanner(onMore: () => _openSettings(context)), + SyncTroubleBadge( + child: IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings), + onPressed: () => _openSettings(context), + ), + ), + ], + ), + ); + + static void _openSettings(BuildContext context) => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: const SingleChildScrollView(child: SyncStatusCard()), + ), + ), + ); +} + +void main() { + /// What the next sync does: null goes through, anything else is thrown. + late Object? nextSync; + + /// Shows the app on test.example.org once a first sync ran, with + /// [pending] changes kept from before that haven't reached the server. + /// (The service isn't started: its timers would outlive the test.) + Future showApp(WidgetTester tester, {int pending = 0}) async { + final sync = SyncService( + ({required onMerge}) => switch (nextSync) { + final Object error => Future.error(error), + null => Future.value(), + }, + pendingChanges: MemoryPendingChangesStore(pending), + )..syncNow(); + addTearDown(sync.dispose); + await tester.pumpWidget( + BlocProvider( + create: (context) => + SyncStatusBloc(sync)..add(const SyncStatusEventStarted()), + child: testApp(const _Home(), dependencies: TestAppDependencies()), + ), + ); + await tester.pumpAndSettle(); + } + + Finder trouble() => find.bySemanticsLabel(RegExp('Sync needs attention')); + + Future openSettings(WidgetTester tester) async { + await tester.tap(find.byTooltip('Settings')); + await tester.pumpAndSettle(); + } + + setUp(() => nextSync = null); + + testWidgets('shows nothing while sync goes through', (tester) async { + await showApp(tester); + + expect(trouble(), findsNothing); + expect(find.byType(MaterialBanner), findsNothing); + }); + + testWidgets('a failed sync puts a dot on the way to Settings, and the ' + 'Sync card says what is wrong', (tester) async { + nextSync = Exception('No connection'); + await showApp(tester); + + expect(trouble(), findsOneWidget); + await openSettings(tester); + expect(find.text('Sync is failing'), findsOneWidget); + expect(find.textContaining('Last attempt failed at'), findsOneWidget); + expect( + find.text('No sync has completed since the app started'), + findsOneWidget, + ); + }); + + testWidgets('the Sync card counts the changes that wait to sync, until a ' + 'sync takes them', (tester) async { + nextSync = Exception('No connection'); + await showApp(tester, pending: 2); + await openSettings(tester); + + expect(find.text('Sync is failing'), findsOneWidget); + expect(find.text('2 changes wait to sync'), findsOneWidget); + + nextSync = null; + await tester.tap(find.text('Sync now')); + await tester.pumpAndSettle(); + expect(find.text('Everything synced'), findsOneWidget); + expect(find.textContaining('wait to sync'), findsNothing); + }); + + testWidgets('Sync now syncs, and the trouble goes once a sync went ' + 'through', (tester) async { + nextSync = Exception('No connection'); + await showApp(tester); + await openSettings(tester); + + nextSync = null; + await tester.tap(find.text('Sync now')); + await tester.pumpAndSettle(); + + expect(find.text('Everything synced'), findsOneWidget); + expect(find.textContaining('Last completed at'), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(trouble(), findsNothing); + }); + + testWidgets("when the app and the server don't match, the banner says so " + 'and More leads to the Sync card, until a sync goes through', ( + tester, + ) async { + nextSync = const AppServerMismatchException(); + await showApp(tester); + + const banner = + "This app can't sync with test.example.org any more. Update it to " + 'keep syncing.'; + expect(find.text(banner), findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(MaterialBanner), + matching: find.text('More'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text("This app can't sync with test.example.org"), + findsOneWidget, + ); + expect( + find.text( + 'Update the app. If it is up to date, the server needs an update. ' + 'What you do meanwhile stays on this device and syncs afterwards.', + ), + findsOneWidget, + ); + + nextSync = null; + await tester.tap(find.text('Sync now')); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text(banner), findsNothing); + }); + + testWidgets('an ordinary failure shows no banner', (tester) async { + nextSync = Exception('No connection'); + await showApp(tester); + + expect(find.textContaining('Update it to keep syncing'), findsNothing); + }); +} diff --git a/packages/halkyon_base_client/lib/src/protocol/client.dart b/packages/halkyon_base_client/lib/src/protocol/client.dart index 41039a4..c8f1313 100644 --- a/packages/halkyon_base_client/lib/src/protocol/client.dart +++ b/packages/halkyon_base_client/lib/src/protocol/client.dart @@ -197,7 +197,7 @@ class EndpointInstance extends _isc.EndpointRef { @override String get name => 'halkyon_base.instance'; - /// This instance's basics: GitHub sign-in only with the operator's OAuth + /// This instance's basics: the app it serves, GitHub sign-in only with the operator's OAuth /// app, email sign-in only where it can mail codes, where its source is, /// the oldest app build it works with, and where it keeps people's data /// and its backups. diff --git a/packages/halkyon_base_client/lib/src/protocol/instance/instance_basics.dart b/packages/halkyon_base_client/lib/src/protocol/instance/instance_basics.dart index ad5ee96..485262b 100644 --- a/packages/halkyon_base_client/lib/src/protocol/instance/instance_basics.dart +++ b/packages/halkyon_base_client/lib/src/protocol/instance/instance_basics.dart @@ -13,12 +13,13 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc; /// What every Halkyon instance tells its apps, to anyone, before anyone signs -/// in: how people sign in here, where the source of what it runs is, the +/// in: which app it serves, how people sign in here, where the source of what it runs is, the /// oldest app build it works with, and where it keeps people's data. Every /// instance is run by someone else, with its own configuration. abstract class InstanceBasics implements _isc.SerializableModel, _isc.ProtocolSerialization { InstanceBasics._({ + required this.app, this.gitHubClientId, required this.emailSignIn, required this.sourceCodeUrl, @@ -28,6 +29,7 @@ abstract class InstanceBasics }); factory InstanceBasics({ + required String app, String? gitHubClientId, required bool emailSignIn, required String sourceCodeUrl, @@ -38,6 +40,7 @@ abstract class InstanceBasics factory InstanceBasics.fromJson(Map jsonSerialization) { return InstanceBasics( + app: jsonSerialization['app'] as String, gitHubClientId: jsonSerialization['gitHubClientId'] as String?, emailSignIn: _isc.BoolJsonExtension.fromJson( jsonSerialization['emailSignIn'], @@ -49,6 +52,10 @@ abstract class InstanceBasics ); } + /// Which app this instance serves (`learn` for Halkyon Learn), as its host + /// configures it: an app accepts only an instance of its own. + String app; + /// The client ID of this instance's GitHub OAuth app, or null when GitHub /// sign-in is off here. String? gitHubClientId; @@ -79,6 +86,7 @@ abstract class InstanceBasics /// with some or all fields replaced by the given arguments. @_isc.useResult InstanceBasics copyWith({ + String? app, String? gitHubClientId, bool? emailSignIn, String? sourceCodeUrl, @@ -90,6 +98,7 @@ abstract class InstanceBasics Map toJson() { return { '__className__': 'halkyon_base.InstanceBasics', + 'app': app, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, 'emailSignIn': emailSignIn, 'sourceCodeUrl': sourceCodeUrl, @@ -103,6 +112,7 @@ abstract class InstanceBasics Map toJsonForProtocol() { return { '__className__': 'halkyon_base.InstanceBasics', + 'app': app, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, 'emailSignIn': emailSignIn, 'sourceCodeUrl': sourceCodeUrl, @@ -122,6 +132,7 @@ class _Undefined {} class _InstanceBasicsImpl extends InstanceBasics { _InstanceBasicsImpl({ + required String app, String? gitHubClientId, required bool emailSignIn, required String sourceCodeUrl, @@ -129,6 +140,7 @@ class _InstanceBasicsImpl extends InstanceBasics { String? dataLocation, String? backupLocation, }) : super._( + app: app, gitHubClientId: gitHubClientId, emailSignIn: emailSignIn, sourceCodeUrl: sourceCodeUrl, @@ -142,6 +154,7 @@ class _InstanceBasicsImpl extends InstanceBasics { @_isc.useResult @override InstanceBasics copyWith({ + String? app, Object? gitHubClientId = _Undefined, bool? emailSignIn, String? sourceCodeUrl, @@ -150,6 +163,7 @@ class _InstanceBasicsImpl extends InstanceBasics { Object? backupLocation = _Undefined, }) { return InstanceBasics( + app: app ?? this.app, gitHubClientId: gitHubClientId is String? ? gitHubClientId : this.gitHubClientId, diff --git a/packages/halkyon_base_server/README.md b/packages/halkyon_base_server/README.md index 95a1e34..cde6ec6 100644 --- a/packages/halkyon_base_server/README.md +++ b/packages/halkyon_base_server/README.md @@ -17,7 +17,7 @@ Endpoints, under the nickname `base` in a host's client (`client.base.emailCode` | `emailCode` | `sendCode`, `verifyCode`: a code by mail; a new address becomes the caller's, another person's gives a switch token | signed in | | `emailSignIn` | `signIn`: a session for the person a switch token names | without a session | | `account` | `delete`, `signInWays` | signed in | -| `instance` | `basics`: GitHub's client ID if any, whether email sign-in is on, the source link, the oldest app build, where data and backups are | anyone | +| `instance` | `basics`: the app it serves, GitHub's client ID if any, whether email sign-in is on, the source link, the oldest app build, where data and backups are | anyone | - GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its @@ -60,6 +60,7 @@ Endpoints, under the nickname `base` in a host's client (`client.base.emailCode` HalkyonBase.configure( pod, HalkyonBase( + app: 'todo', // its apps accept only instances naming it; never change it appName: 'Halkyon Todo', // in sign-in mails upstreamSourceCodeUrl: 'https://code.halkyon.app/project-halkyon/halkyon_todo', minimumAppBuild: 1, // raise it in the commit that breaks older apps diff --git a/packages/halkyon_base_server/lib/src/generated/instance/instance_basics.dart b/packages/halkyon_base_server/lib/src/generated/instance/instance_basics.dart index c254347..bf7c032 100644 --- a/packages/halkyon_base_server/lib/src/generated/instance/instance_basics.dart +++ b/packages/halkyon_base_server/lib/src/generated/instance/instance_basics.dart @@ -13,12 +13,13 @@ import 'package:serverpod/serverpod.dart' as _is; /// What every Halkyon instance tells its apps, to anyone, before anyone signs -/// in: how people sign in here, where the source of what it runs is, the +/// in: which app it serves, how people sign in here, where the source of what it runs is, the /// oldest app build it works with, and where it keeps people's data. Every /// instance is run by someone else, with its own configuration. abstract class InstanceBasics implements _is.SerializableModel, _is.ProtocolSerialization { InstanceBasics._({ + required this.app, this.gitHubClientId, required this.emailSignIn, required this.sourceCodeUrl, @@ -28,6 +29,7 @@ abstract class InstanceBasics }); factory InstanceBasics({ + required String app, String? gitHubClientId, required bool emailSignIn, required String sourceCodeUrl, @@ -38,6 +40,7 @@ abstract class InstanceBasics factory InstanceBasics.fromJson(Map jsonSerialization) { return InstanceBasics( + app: jsonSerialization['app'] as String, gitHubClientId: jsonSerialization['gitHubClientId'] as String?, emailSignIn: _is.BoolJsonExtension.fromJson( jsonSerialization['emailSignIn'], @@ -49,6 +52,10 @@ abstract class InstanceBasics ); } + /// Which app this instance serves (`learn` for Halkyon Learn), as its host + /// configures it: an app accepts only an instance of its own. + String app; + /// The client ID of this instance's GitHub OAuth app, or null when GitHub /// sign-in is off here. String? gitHubClientId; @@ -79,6 +86,7 @@ abstract class InstanceBasics /// with some or all fields replaced by the given arguments. @_is.useResult InstanceBasics copyWith({ + String? app, String? gitHubClientId, bool? emailSignIn, String? sourceCodeUrl, @@ -90,6 +98,7 @@ abstract class InstanceBasics Map toJson() { return { '__className__': 'halkyon_base.InstanceBasics', + 'app': app, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, 'emailSignIn': emailSignIn, 'sourceCodeUrl': sourceCodeUrl, @@ -103,6 +112,7 @@ abstract class InstanceBasics Map toJsonForProtocol() { return { '__className__': 'halkyon_base.InstanceBasics', + 'app': app, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, 'emailSignIn': emailSignIn, 'sourceCodeUrl': sourceCodeUrl, @@ -122,6 +132,7 @@ class _Undefined {} class _InstanceBasicsImpl extends InstanceBasics { _InstanceBasicsImpl({ + required String app, String? gitHubClientId, required bool emailSignIn, required String sourceCodeUrl, @@ -129,6 +140,7 @@ class _InstanceBasicsImpl extends InstanceBasics { String? dataLocation, String? backupLocation, }) : super._( + app: app, gitHubClientId: gitHubClientId, emailSignIn: emailSignIn, sourceCodeUrl: sourceCodeUrl, @@ -142,6 +154,7 @@ class _InstanceBasicsImpl extends InstanceBasics { @_is.useResult @override InstanceBasics copyWith({ + String? app, Object? gitHubClientId = _Undefined, bool? emailSignIn, String? sourceCodeUrl, @@ -150,6 +163,7 @@ class _InstanceBasicsImpl extends InstanceBasics { Object? backupLocation = _Undefined, }) { return InstanceBasics( + app: app ?? this.app, gitHubClientId: gitHubClientId is String? ? gitHubClientId : this.gitHubClientId, diff --git a/packages/halkyon_base_server/lib/src/halkyon_base.dart b/packages/halkyon_base_server/lib/src/halkyon_base.dart index 888fcd9..9055b8e 100644 --- a/packages/halkyon_base_server/lib/src/halkyon_base.dart +++ b/packages/halkyon_base_server/lib/src/halkyon_base.dart @@ -20,12 +20,18 @@ typedef AfterDeletion = Future Function(); /// pod, before the pod starts ([configure]). final class HalkyonBase { HalkyonBase({ + required this.app, required this.appName, required this.upstreamSourceCodeUrl, required this.minimumAppBuild, this.deletePersonData = const [], }); + /// Which app this server serves, as its apps know it (`learn` for Halkyon + /// Learn): an instance names it in its basics, so an app recognises its own + /// instances and refuses another app's. Never change it once apps use it. + final String app; + /// The app's name as people know it, such as "Halkyon Learn": sign-in /// mails name it in their subject and text, and as their sender. final String appName; diff --git a/packages/halkyon_base_server/lib/src/instance/instance_basics.spy.yaml b/packages/halkyon_base_server/lib/src/instance/instance_basics.spy.yaml index 04b8cbc..c3bbfd1 100644 --- a/packages/halkyon_base_server/lib/src/instance/instance_basics.spy.yaml +++ b/packages/halkyon_base_server/lib/src/instance/instance_basics.spy.yaml @@ -1,9 +1,12 @@ ### What every Halkyon instance tells its apps, to anyone, before anyone signs -### in: how people sign in here, where the source of what it runs is, the +### in: which app it serves, how people sign in here, where the source of what it runs is, the ### oldest app build it works with, and where it keeps people's data. Every ### instance is run by someone else, with its own configuration. class: InstanceBasics fields: + ### Which app this instance serves (`learn` for Halkyon Learn), as its host + ### configures it: an app accepts only an instance of its own. + app: String ### The client ID of this instance's GitHub OAuth app, or null when GitHub ### sign-in is off here. gitHubClientId: String? diff --git a/packages/halkyon_base_server/lib/src/instance/instance_endpoint.dart b/packages/halkyon_base_server/lib/src/instance/instance_endpoint.dart index ca04d66..38ff91b 100644 --- a/packages/halkyon_base_server/lib/src/instance/instance_endpoint.dart +++ b/packages/halkyon_base_server/lib/src/instance/instance_endpoint.dart @@ -9,19 +9,21 @@ import 'package:serverpod/serverpod.dart'; /// What every Halkyon instance tells its apps. Public: an app asks before /// anyone is signed in. class InstanceEndpoint extends Endpoint { - /// This instance's basics: GitHub sign-in only with the operator's OAuth + /// This instance's basics: the app it serves, GitHub sign-in only with the operator's OAuth /// app, email sign-in only where it can mail codes, where its source is, /// the oldest app build it works with, and where it keeps people's data /// and its backups. Future basics(Session session) async { final pod = session.serverpod; + final base = HalkyonBase.of(pod); return InstanceBasics( + app: base.app, gitHubClientId: gitHubSignInAvailable(pod) ? pod.getPassword('githubClientId') : null, emailSignIn: emailSignInAvailable(pod), sourceCodeUrl: sourceCodeUrl(pod), - minimumAppBuild: HalkyonBase.of(pod).minimumAppBuild, + minimumAppBuild: base.minimumAppBuild, dataLocation: dataLocation(), backupLocation: backupLocation(), ); diff --git a/packages/halkyon_base_server/test/integration/instance/instance_test.dart b/packages/halkyon_base_server/test/integration/instance/instance_test.dart index 2fbf218..dc026f5 100644 --- a/packages/halkyon_base_server/test/integration/instance/instance_test.dart +++ b/packages/halkyon_base_server/test/integration/instance/instance_test.dart @@ -32,6 +32,10 @@ void main() { ); }); + test('names the app it serves, so an app recognises its own instances', () { + expect(basics.app, testApp); + }); + test("names where the host's project publishes its source when the " 'operator set nothing', () { // The tests run without SOURCE_URL. diff --git a/packages/halkyon_base_server/test/integration/support/fixtures.dart b/packages/halkyon_base_server/test/integration/support/fixtures.dart index 69b1a9e..b39e25f 100644 --- a/packages/halkyon_base_server/test/integration/support/fixtures.dart +++ b/packages/halkyon_base_server/test/integration/support/fixtures.dart @@ -7,6 +7,9 @@ import '../test_tools/serverpod_test_tools.dart'; /// The app the tests' host is. const testAppName = 'Halkyon Test'; +/// The id of the app the tests' host serves. +const testApp = 'test'; + /// Where the tests' host says its project publishes its source. const testUpstreamSourceCodeUrl = 'https://code.example.org/halkyon_test'; @@ -26,6 +29,7 @@ void useBase( HalkyonBase.configure( pod, HalkyonBase( + app: testApp, appName: testAppName, upstreamSourceCodeUrl: testUpstreamSourceCodeUrl, minimumAppBuild: testMinimumAppBuild, diff --git a/packages/halkyon_core/lib/halkyon_core.dart b/packages/halkyon_core/lib/halkyon_core.dart index e55e132..79a3d5f 100644 --- a/packages/halkyon_core/lib/halkyon_core.dart +++ b/packages/halkyon_core/lib/halkyon_core.dart @@ -5,6 +5,7 @@ /// - A thin `generateUuid` wrapper for v4 UUID generation. /// - A `Logger` interface and basic `ConsoleLogger` implementation. /// - The optional `HalkyonException` marker interface. +/// - `SerialQueue` and `CoalescingRunner`, which run work one at a time. library; // Re-export the clock package directly. Halkyon apps depend on `Clock` for @@ -12,6 +13,8 @@ library; // without needing a separate `clock` dependency. export 'package:clock/clock.dart' show Clock, clock; +export 'src/async/coalescing_runner.dart'; +export 'src/async/serial_queue.dart'; export 'src/exceptions.dart'; export 'src/logger.dart'; export 'src/uuid.dart'; diff --git a/packages/halkyon_core/lib/src/async/coalescing_runner.dart b/packages/halkyon_core/lib/src/async/coalescing_runner.dart new file mode 100644 index 0000000..f913da8 --- /dev/null +++ b/packages/halkyon_core/lib/src/async/coalescing_runner.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +/// Runs an action one run at a time: asked again while a run is under way, +/// it runs once more after it, however often it was asked, so a burst of +/// changes (a sync brings many) costs one more run, not one each, and an +/// older result never overtakes a newer one. +class CoalescingRunner( + /// What a run does. + final Future Function() _action, { + + /// Hears of a run that failed; a run asked for meanwhile follows anyway. + required final void Function(Object error, StackTrace stackTrace) _onError, +}) { + /// The runs under way, one asked for meanwhile included; null while none + /// is: a completed future kept from before would run what waits for it in + /// the zone it was made in, which a widget test's fake clock never + /// reaches. + Future? _running; + + /// Whether to run once more after the run under way. + var _again = false; + + /// Runs now, or once more after the run under way; completes once no run + /// is under way or asked for. + Future run() { + if (_running case final running?) { + _again = true; + return running; + } + final done = Completer(); + // Set before the first run starts, so a call during it waits its turn. + _running = done.future; + unawaited(() async { + do { + _again = false; + try { + await _action(); + } on Object catch (error, st) { + _onError(error, st); + } + } while (_again); + _running = null; + done.complete(); + }()); + return done.future; + } + + /// Completes once no run is under way or asked for; at once when none is. + Future get idle => _running ?? Future.value(); +} diff --git a/packages/halkyon_core/lib/src/async/serial_queue.dart b/packages/halkyon_core/lib/src/async/serial_queue.dart new file mode 100644 index 0000000..3f0dc5f --- /dev/null +++ b/packages/halkyon_core/lib/src/async/serial_queue.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +/// Runs actions one after another, each once the one before is done, failed +/// or not: a repository's reads and writes, say, so that none meets another +/// half done. +class SerialQueue() { + // Null while nothing runs: a completed future kept from before would run + // what waits for it in the zone it was made in, which a widget test's fake + // clock never reaches. + Future? _last; + + /// Runs [action] once everything queued before it is done; completes as + /// it does. + Future run(Future Function() action) async { + final previous = _last; + final done = Completer(); + _last = done.future; + try { + await previous; + return await action(); + } finally { + done.complete(); + if (identical(_last, done.future)) { + _last = null; + } + } + } +} diff --git a/packages/halkyon_core/test/async/coalescing_runner_test.dart b/packages/halkyon_core/test/async/coalescing_runner_test.dart new file mode 100644 index 0000000..307c414 --- /dev/null +++ b/packages/halkyon_core/test/async/coalescing_runner_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:halkyon_core/halkyon_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('asked again while it runs, it runs once more after, however often ' + 'asked; every caller waits for that too', () async { + final log = []; + var gate = Completer(); + var runs = 0; + final runner = CoalescingRunner(() async { + final run = ++runs; + log.add('run $run starts'); + await gate.future; + log.add('run $run ends'); + }, onError: (_, _) {}); + + final first = runner.run(); + final second = runner.run(); + final third = runner.run(); + await pumpEventQueue(); + expect(log, ['run 1 starts']); + + final firstGate = gate; + gate = Completer()..complete(); + firstGate.complete(); + await Future.wait([first, second, third]); + + expect(log, ['run 1 starts', 'run 1 ends', 'run 2 starts', 'run 2 ends']); + expect(runs, 2); + }); + + test('a run that fails is reported, and one asked for meanwhile still ' + 'follows', () async { + final errors = []; + final gate = Completer(); + var runs = 0; + final runner = CoalescingRunner(() async { + runs++; + if (runs == 1) { + await gate.future; + throw StateError('disk unavailable'); + } + }, onError: (error, _) => errors.add(error)); + + final first = runner.run(); + final again = runner.run(); + gate.complete(); + await Future.wait([first, again]); + + expect(errors, [isA()]); + expect(runs, 2); + }); + + test('idle completes once nothing runs; afterwards a run starts ' + 'afresh', () async { + var runs = 0; + final runner = CoalescingRunner(() async => runs++, onError: (_, _) {}); + await runner.idle; + + unawaited(runner.run()); + await runner.idle; + await runner.run(); + + expect(runs, 2); + }); +} diff --git a/packages/halkyon_core/test/async/serial_queue_test.dart b/packages/halkyon_core/test/async/serial_queue_test.dart new file mode 100644 index 0000000..30b29b3 --- /dev/null +++ b/packages/halkyon_core/test/async/serial_queue_test.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:halkyon_core/halkyon_core.dart'; +import 'package:test/test.dart'; + +void main() { + late SerialQueue queue; + + setUp(() => queue = SerialQueue()); + + test('runs one action after another, in the order queued', () async { + final log = []; + final first = Completer(); + + final a = queue.run(() async { + log.add('a starts'); + await first.future; + log.add('a ends'); + }); + final b = queue.run(() async => log.add('b')); + await Future.delayed(Duration.zero); + expect(log, ['a starts']); + + first.complete(); + await Future.wait([a, b]); + expect(log, ['a starts', 'a ends', 'b']); + }); + + test('completes with what the action returns, or its error, and goes on ' + 'after an error', () async { + final failing = queue.run(() async => throw StateError('broken')); + final next = queue.run(() async => 42); + + await expectLater(failing, throwsStateError); + expect(await next, 42); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 7950ee9..66ca455 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,7 @@ environment: sdk: ^3.13.0 workspace: + - packages/halkyon_app - packages/halkyon_base_client - packages/halkyon_base_server - packages/halkyon_core