diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b6d9369f --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Injectable generated files +di_initializer.config.dart + +# mockito generated files +*.mock.dart + +# l10n generated files: +lib/generated/* +untranslated_messages.txt \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..5a023280 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7e9793dee1b85a243edd0e06cb1658e98b077561 + channel: stable + +project_type: app diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..0f4ca79c --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ + + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] + + + + + +
+
+ + Logo + + +

Paperless Mobile

+ +

+ An (almost) fully fledged mobile paperless client. +
+
+ + Report Bug + · + Request Feature +

+
+ + +## About The Project +With this app you can conveniently add, manage or simply find documents stored in your paperless server without any comproimises. This project started as a small fun side project to learn more about the Flutter framework and because existing solutions didn't fulfill my needs, but it has grown much faster with far more features than I originally anticipated. + + +### :rocket: Features +:heavy_check_mark: **View** your documents at a glance, in a compact list or a more detailed grid view
+:heavy_check_mark: **Add**, **delete**, **edit**, ~**download**~ or **preview** your documents
+:heavy_check_mark: **Manage** and assign correspondents, document types, tags and storage paths
+:heavy_check_mark: **Scan** and upload documents with preset correspondent, document type, tags and creation date
+:heavy_check_mark: **Search** for documents using a wide range of filter criteria
+:heavy_check_mark: **Secure** your data with **biometric authentication** across sessions
+:heavy_check_mark: Support for **TLS mutual authentication** (client certificates)
+:heavy_check_mark: **Modern, intuitive UI** built according to the Material Design 3 specification
+:heavy_check_mark: Available in english and german language (more to come!) + + +### Built With + +[![Flutter][Flutter]][Flutter-url] + + + +## Getting Started + +To get a local copy up and running follow these simple steps. + +### Prerequisites + +* Install the Flutter SDK (https://docs.flutter.dev/get-started/install) +* Install an IDE of your choice (e.g. VSCode with the Dart/Flutter extensions) + +### Installation + +1. Clone the repo + ```sh + git clone https://github.com/astubenbord/paperless-mobile.git + ``` +2. Install the dependencies (should be done automatically by your IDE) and generate localization files + ```sh + flutter pub get + ``` +3. Build generated files (e.g. for injectable library) + ```sh + flutter packages pub run build_runner build --delete-conflicting-outputs + ``` + + +## Roadmap +- [ ] Add download functionality (implemented, but flutter cannot download to useful directories except app directory) +- [ ] Improvements to UX (e.g. form fields show clear button while empty) +- [ ] Add more languages +- [ ] Support for IOS +- [ ] Automatic releases and CI/CD with fastlane +- [ ] Templates for recurring scans (e.g. monthly payrolls with same title, dates at end of month, fixed correspondent and document type) +- [ ] Custom document scanner optimized for common white A4 documents (currently using edge_detection, which is okay but not optimal for this use case) +- [ ] Support multiple instances (low prio) + +See the [open issues](https://github.com/astubenbord/paperless-mobile/issues) for a full list of proposed features (and known issues). + + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. +All bug reports or feature requests are welcome, even if you can't contribute code! + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". +Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + + + +## License + +Distributed under the GNU General Public License v3.0. See `LICENSE.txt` for more information. + + + +## Screenshots +Here are some impressions from the app! + +#### Login Page + + +#### Documents Overview (List) + + +#### Documents Overview (Grid) + + +#### Document Filter/Search (More filters below!) + + +#### Document Details + + +#### Edit Document + + +#### Scan + + +#### Upload + + + + +[contributors-shield]: https://img.shields.io/github/contributors/astubenbord/paperless-mobile.svg?style=for-the-badge +[contributors-url]: https://github.com/astubenbord/paperless-mobile/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/astubenbord/paperless-mobile.svg?style=for-the-badge +[forks-url]: https://github.com/astubenbord/paperless-mobile/network/members +[stars-shield]: https://img.shields.io/github/stars/astubenbord/paperless-mobile.svg?style=for-the-badge +[stars-url]: https://github.com/astubenbord/paperless-mobile/stargazers +[issues-shield]: https://img.shields.io/github/issues/astubenbord/paperless-mobile.svg?style=for-the-badge +[issues-url]: https://github.com/astubenbord/paperless-mobile/issues +[license-shield]: https://img.shields.io/github/license/astubenbord/paperless-mobile.svg?style=for-the-badge +[license-url]: https://github.com/astubenbord/paperless-mobile/blob/main/LICENSE +[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 +[linkedin-url]: https://linkedin.com/in/linkedin_username +[product-screenshot]: images/screenshot.png +[Flutter]: https://img.shields.io/badge/Flutter-02569B?style=for-the-badge&logo=flutter&logoColor=white +[Flutter-url]: https://flutter.dev + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..61b6c4de --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..a3396284 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,68 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 31 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "de.antonjstu.flutter_paperless_mobile" + minSdkVersion 21 + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..12e47ca0 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..57e8b5c1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/flutter_paperless_ng_scan_and_share/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_paperless_ng_scan_and_share/MainActivity.kt new file mode 100644 index 00000000..3a7ae224 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_paperless_ng_scan_and_share/MainActivity.kt @@ -0,0 +1,13 @@ +package com.example.flutter_paperless_mobile + +import android.os.Bundle +import android.view.WindowManager.LayoutParams +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity : FlutterFragmentActivity() { + + override fun onCreate(bundle: Bundle?) { + super.onCreate(bundle) + //getWindow().addFlags(LayoutParams.FLAG_SECURE) + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 00000000..970d2f71 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 00000000..6c1743df Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png new file mode 100644 index 00000000..207991ae Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png new file mode 100644 index 00000000..5510609a Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 00000000..b17092ef Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png new file mode 100644 index 00000000..c0cccda0 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png new file mode 100644 index 00000000..71f7c450 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png new file mode 100644 index 00000000..ef702e98 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 00000000..b17092ef Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 00000000..c47e9c59 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..e0158f83 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..0f65e4a4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..64cf6117 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 00000000..c47e9c59 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..ecf481f8 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..a7f1a7e7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..5e1cb47a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..4b9ccd0e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..de6a7a35 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..c831fec9 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..f5fdb6b7 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..0d151866 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..12e47ca0 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..d77ed1c5 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.5.31' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.2.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..562c5e44 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/assets/images/empty-state.svg b/assets/images/empty-state.svg new file mode 100644 index 00000000..17d6e035 --- /dev/null +++ b/assets/images/empty-state.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/splash.png b/assets/images/splash.png new file mode 100644 index 00000000..ead6da8e Binary files /dev/null and b/assets/images/splash.png differ diff --git a/assets/logos/paperless_logo_black.png b/assets/logos/paperless_logo_black.png new file mode 100644 index 00000000..522f0d7e Binary files /dev/null and b/assets/logos/paperless_logo_black.png differ diff --git a/assets/logos/paperless_logo_black.svg b/assets/logos/paperless_logo_black.svg new file mode 100644 index 00000000..6cb7ac50 --- /dev/null +++ b/assets/logos/paperless_logo_black.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/paperless_logo_green.png b/assets/logos/paperless_logo_green.png new file mode 100644 index 00000000..4a6f7f7c Binary files /dev/null and b/assets/logos/paperless_logo_green.png differ diff --git a/assets/logos/paperless_logo_green.svg b/assets/logos/paperless_logo_green.svg new file mode 100644 index 00000000..3144ae90 --- /dev/null +++ b/assets/logos/paperless_logo_green.svg @@ -0,0 +1,82 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/paperless_logo_white.png b/assets/logos/paperless_logo_white.png new file mode 100644 index 00000000..eb1d09d1 Binary files /dev/null and b/assets/logos/paperless_logo_white.png differ diff --git a/assets/logos/paperless_logo_white.svg b/assets/logos/paperless_logo_white.svg new file mode 100644 index 00000000..477c7465 --- /dev/null +++ b/assets/logos/paperless_logo_white.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 00000000..c6067c0a --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,5 @@ +flutter_icons: + android: "launcher_icon" + ios: true + image_path: "assets/logos/paperless_logo_green.png" + min_sdk_android: 21 # android min sdk min:16, default 21 \ No newline at end of file diff --git a/integration_test/test_add_document.dart b/integration_test/test_add_document.dart new file mode 100644 index 00000000..6f8d044c --- /dev/null +++ b/integration_test/test_add_document.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('screenshot', (WidgetTester tester) async { + // Build the app. + + // This is required prior to taking the screenshot (Android only). + await binding.convertFlutterSurfaceToImage(); + + // Trigger a frame. + await tester.pumpAndSettle(); + await binding.takeScreenshot('screenshot-1'); + }); +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..9625e105 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..88359b22 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 00000000..25334de3 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,167 @@ +PODS: + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - edge_detection (1.0.9): + - Flutter + - WeScan + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_keyboard_visibility (0.0.1): + - Flutter + - flutter_native_splash (0.0.1): + - Flutter + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - integration_test (0.0.1): + - Flutter + - local_auth_ios (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_ios (0.0.1): + - Flutter + - pdfx (1.0.0): + - Flutter + - permission_handler_apple (9.0.4): + - Flutter + - ReachabilitySwift (5.0.0) + - SDWebImage (5.13.5): + - SDWebImage/Core (= 5.13.5) + - SDWebImage/Core (5.13.5) + - shared_preferences_ios (0.0.1): + - Flutter + - sqflite (0.0.2): + - Flutter + - FMDB (>= 2.7.5) + - SwiftyGif (5.4.3) + - url_launcher_ios (0.0.1): + - Flutter + - WeScan (1.7.0) + +DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - edge_detection (from `.symlinks/plugins/edge_detection/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - pdfx (from `.symlinks/plugins/pdfx/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - FMDB + - ReachabilitySwift + - SDWebImage + - SwiftyGif + - WeScan + +EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + edge_detection: + :path: ".symlinks/plugins/edge_detection/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_keyboard_visibility: + :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + local_auth_ios: + :path: ".symlinks/plugins/local_auth_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" + pdfx: + :path: ".symlinks/plugins/pdfx/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e + device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + edge_detection: 9bc5ee35073b5a17c0b3b679908f01017ce3062a + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 + local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d + package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e + path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec + permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370 + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 + url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.11.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..cef9439a --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,549 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B8F579F7EE511C92B2614EE2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 40BF6B81A87C86D22CDB775A /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 40BF6B81A87C86D22CDB775A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 895AB075C3F1E3E87F6C3D1A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9F0882A26646B3A4713EAA3B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + B6A32B98ED8D2D9794070543 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B8F579F7EE511C92B2614EE2 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + E525DE4999AE527627D97DCA /* Pods */, + FB6F7F4A953DAAA3FFE794E6 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + E525DE4999AE527627D97DCA /* Pods */ = { + isa = PBXGroup; + children = ( + B6A32B98ED8D2D9794070543 /* Pods-Runner.debug.xcconfig */, + 9F0882A26646B3A4713EAA3B /* Pods-Runner.release.xcconfig */, + 895AB075C3F1E3E87F6C3D1A /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + FB6F7F4A953DAAA3FFE794E6 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 40BF6B81A87C86D22CDB775A /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 0D9B1245DFA4E4B02C265385 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 13B6B31A3992BA65B73408A6 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0D9B1245DFA4E4B02C265385 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 13B6B31A3992BA65B73408A6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaperlessNgScanAndShare; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaperlessNgScanAndShare; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaperlessNgScanAndShare; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..c87d15a3 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..04baef2e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..2deb5cfc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..89d1d460 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..68003bdc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..83804fbb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..8cbac360 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..27a1ccb6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..89d1d460 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..8468f1e4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..2fba5fe8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..8fa987f0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..2e740c5b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..daaa6541 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..066d126f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..2fba5fe8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..a3d1245f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..ecf481f8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..4b9ccd0e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..50614411 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..bad9f84d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..ae6ab370 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 00000000..fa313278 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "darkbackground.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 00000000..c47e9c59 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png new file mode 100644 index 00000000..b17092ef Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..f3387d4a --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..6c1743df Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..e0158f83 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..0f65e4a4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png new file mode 100644 index 00000000..5510609a Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png new file mode 100644 index 00000000..c0cccda0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png new file mode 100644 index 00000000..71f7c450 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..ec7f3b2b --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..3aaff9b1 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + NSFaceIDUsageDescription + Why is my app authenticating using face id? + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Paperless Mobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + flutter_paperless_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UIStatusBarHidden + + CADisableMinimumFrameDurationOnPhone + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 00000000..05fecc97 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: intl_en.arb +output-localization-file: app_localizations.dart +untranslated-messages-file: untranslated_messages.txt \ No newline at end of file diff --git a/lib/core/bloc/connectivity_cubit.dart b/lib/core/bloc/connectivity_cubit.dart new file mode 100644 index 00000000..2b9b97fa --- /dev/null +++ b/lib/core/bloc/connectivity_cubit.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class ConnectivityCubit extends Cubit { + final ConnectivityStatusService connectivityStatusService; + late final StreamSubscription _sub; + + ConnectivityCubit(this.connectivityStatusService) : super(ConnectivityState.undefined); + + Future initialize() async { + final bool isConnected = await connectivityStatusService.isConnectedToInternet(); + emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected); + _sub = connectivityStatusService.connectivityChanges().listen((isConnected) { + emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected); + }); + } + + @override + Future close() { + _sub.cancel(); + return super.close(); + } +} + +enum ConnectivityState { connected, notConnected, undefined } diff --git a/lib/core/bloc/document_status_cubit.dart b/lib/core/bloc/document_status_cubit.dart new file mode 100644 index 00000000..e996fdd3 --- /dev/null +++ b/lib/core/bloc/document_status_cubit.dart @@ -0,0 +1,10 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/document_processing_status.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class DocumentStatusCubit extends Cubit { + DocumentStatusCubit() : super(null); + + void updateStatus(DocumentProcessingStatus? status) => emit(status); +} diff --git a/lib/core/bloc/label_bloc_provider.dart b/lib/core/bloc/label_bloc_provider.dart new file mode 100644 index 00000000..13542821 --- /dev/null +++ b/lib/core/bloc/label_bloc_provider.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; + +class LabelBlocProvider extends StatelessWidget { + final Widget child; + const LabelBlocProvider({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + ], + child: child, + ); + } +} diff --git a/lib/core/bloc/label_cubit.dart b/lib/core/bloc/label_cubit.dart new file mode 100644 index 00000000..20776daa --- /dev/null +++ b/lib/core/bloc/label_cubit.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/repository/label_repository.dart'; + +abstract class LabelCubit extends Cubit> { + final LabelRepository labelRepository; + LabelCubit(this.labelRepository) : super({}); + + @protected + void loadFrom(Iterable items) => emit(Map.fromIterable(items, key: (e) => (e as T).id!)); + + Future add(T item) async { + assert(item.id == null); + final addedItem = await save(item); + final newState = {...state}; + newState.putIfAbsent(addedItem.id!, () => addedItem); + emit(newState); + return addedItem; + } + + Future replace(T item) async { + assert(item.id != null); + final updatedItem = await update(item); + final newState = {...state}; + newState[item.id!] = updatedItem; + emit(newState); + return updatedItem; + } + + Future remove(T item) async { + assert(item.id != null); + if (state.containsKey(item.id)) { + final deletedId = await delete(item); + final newState = {...state}; + newState.remove(deletedId); + emit(newState); + } + } + + void reset() => emit({}); + + Future initialize(); + + @protected + Future save(T item); + + @protected + Future update(T item); + + @protected + Future delete(T item); +} diff --git a/lib/core/global/http_self_signed_certificate_override.dart b/lib/core/global/http_self_signed_certificate_override.dart new file mode 100644 index 00000000..b8b9ed08 --- /dev/null +++ b/lib/core/global/http_self_signed_certificate_override.dart @@ -0,0 +1,11 @@ +// Fix for accepting self signed certificates. +import 'dart:io'; + +class X509HttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} diff --git a/lib/core/interceptor/authentication.interceptor.dart b/lib/core/interceptor/authentication.interceptor.dart new file mode 100644 index 00000000..cd6f8aed --- /dev/null +++ b/lib/core/interceptor/authentication.interceptor.dart @@ -0,0 +1,34 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class AuthenticationInterceptor implements InterceptorContract { + AuthenticationCubit authenticationCubit; + AuthenticationInterceptor(this.authenticationCubit); + + @override + Future interceptRequest({required BaseRequest request}) async { + final authState = authenticationCubit.state; + if (kDebugMode) { + log("Intercepted request to ${request.url.toString()}"); + } + if (authState.authentication == null) { + throw const ErrorMessage(ErrorCode.notAuthenticated); + } + return request.copyWith( + //Append server Url + url: Uri.parse(authState.authentication!.serverUrl + request.url.toString()), + headers: authState.authentication!.token.isEmpty + ? request.headers + : {...request.headers, 'Authorization': 'Token ${authState.authentication!.token}'}, + ); + } + + @override + Future interceptResponse({required BaseResponse response}) async => response; +} diff --git a/lib/core/interceptor/connection_state.interceptor.dart b/lib/core/interceptor/connection_state.interceptor.dart new file mode 100644 index 00000000..cfa19bf6 --- /dev/null +++ b/lib/core/interceptor/connection_state.interceptor.dart @@ -0,0 +1,31 @@ +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class ConnectionStateInterceptor implements InterceptorContract { + final AuthenticationCubit authenticationCubit; + final ConnectivityStatusService connectivityStatusService; + ConnectionStateInterceptor( + this.authenticationCubit, this.connectivityStatusService); + + @override + Future interceptRequest({required BaseRequest request}) async { + if (!(await connectivityStatusService.isConnectedToInternet())) { + throw const ErrorMessage(ErrorCode.deviceOffline); + } + final isServerReachable = + await connectivityStatusService.isServerReachable(request.url.origin); + if (!isServerReachable) { + throw const ErrorMessage(ErrorCode.serverUnreachable); + } + return request; + } + + @override + Future interceptResponse( + {required BaseResponse response}) async => + response; +} diff --git a/lib/core/interceptor/language_header.interceptor.dart b/lib/core/interceptor/language_header.interceptor.dart new file mode 100644 index 00000000..5a7ec795 --- /dev/null +++ b/lib/core/interceptor/language_header.interceptor.dart @@ -0,0 +1,25 @@ +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class LanguageHeaderInterceptor implements InterceptorContract { + final ApplicationSettingsCubit appSettingsCubit; + + LanguageHeaderInterceptor(this.appSettingsCubit); + + @override + Future interceptRequest({required BaseRequest request}) async { + late String languages; + if (appSettingsCubit.state.preferredLocaleSubtag == "en") { + languages = "en"; + } else { + languages = appSettingsCubit.state.preferredLocaleSubtag + ",en;q=0.7,en-US;q=0.6"; + } + request.headers.addAll({"Accept-Language": languages}); + return request; + } + + @override + Future interceptResponse({required BaseResponse response}) async => response; +} diff --git a/lib/core/interceptor/response_conversion.interceptor.dart b/lib/core/interceptor/response_conversion.interceptor.dart new file mode 100644 index 00000000..5f0075b4 --- /dev/null +++ b/lib/core/interceptor/response_conversion.interceptor.dart @@ -0,0 +1,32 @@ +import 'package:http/http.dart'; +import 'package:http_interceptor/http/http.dart'; +import 'package:injectable/injectable.dart'; + +const interceptedRoutes = ['thumb/']; + +@injectable +class ResponseConversionInterceptor implements InterceptorContract { + @override + Future interceptRequest({required BaseRequest request}) async => request; + + @override + Future interceptResponse({required BaseResponse response}) async { + final String requestUrl = response.request?.url.toString().split("?").first ?? ''; + if (response.request?.method == "GET" && + interceptedRoutes.any((element) => requestUrl.endsWith(element))) { + final resp = response as Response; + + return StreamedResponse( + Stream.value(resp.bodyBytes.toList()).asBroadcastStream(), + resp.statusCode, + contentLength: resp.contentLength, + headers: resp.headers, + isRedirect: resp.isRedirect, + persistentConnection: false, + reasonPhrase: resp.reasonPhrase, + request: resp.request, + ); + } + return response; + } +} diff --git a/lib/core/logic/error_code_localization_mapper.dart b/lib/core/logic/error_code_localization_mapper.dart new file mode 100644 index 00000000..22838e87 --- /dev/null +++ b/lib/core/logic/error_code_localization_mapper.dart @@ -0,0 +1,70 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +String translateError(BuildContext context, ErrorCode code) { + switch (code) { + case ErrorCode.unknown: + return S.of(context).errorMessageUnknonwnError; + case ErrorCode.authenticationFailed: + return S.of(context).errorMessageAuthenticationFailed; + case ErrorCode.notAuthenticated: + return S.of(context).errorMessageNotAuthenticated; + case ErrorCode.documentUploadFailed: + return S.of(context).errorMessageDocumentUploadFailed; + case ErrorCode.documentUpdateFailed: + return S.of(context).errorMessageDocumentUpdateFailed; + case ErrorCode.documentLoadFailed: + return S.of(context).errorMessageDocumentLoadFailed; + case ErrorCode.documentDeleteFailed: + return S.of(context).errorMessageDocumentDeleteFailed; + case ErrorCode.documentPreviewFailed: + return S.of(context).errorMessageDocumentPreviewFailed; + case ErrorCode.documentAsnQueryFailed: + return S.of(context).errorMessageDocumentAsnQueryFailed; + case ErrorCode.tagCreateFailed: + return S.of(context).errorMessageTagCreateFailed; + case ErrorCode.tagLoadFailed: + return S.of(context).errorMessageTagLoadFailed; + case ErrorCode.documentTypeCreateFailed: + return S.of(context).errorMessageDocumentTypeCreateFailed; + case ErrorCode.documentTypeLoadFailed: + return S.of(context).errorMessageDocumentTypeLoadFailed; + case ErrorCode.correspondentCreateFailed: + return S.of(context).errorMessageCorrespondentCreateFailed; + case ErrorCode.correspondentLoadFailed: + return S.of(context).errorMessageCorrespondentLoadFailed; + case ErrorCode.scanRemoveFailed: + return S.of(context).errorMessageScanRemoveFailed; + case ErrorCode.invalidClientCertificateConfiguration: + return S.of(context).errorMessageInvalidClientCertificateConfiguration; + case ErrorCode.documentBulkDeleteFailed: + return S.of(context).errorMessageBulkDeleteDocumentsFailed; + case ErrorCode.biometricsNotSupported: + return S.of(context).errorMessageBiotmetricsNotSupported; + case ErrorCode.biometricAuthenticationFailed: + return S.of(context).errorMessageBiometricAuthenticationFailed; + case ErrorCode.deviceOffline: + return S.of(context).errorMessageDeviceOffline; + case ErrorCode.serverUnreachable: + return S.of(context).errorMessageServerUnreachable; + case ErrorCode.similarQueryError: + return S.of(context).errorMessageSimilarQueryError; + case ErrorCode.autocompleteQueryError: + return S.of(context).errorMessageAutocompleteQueryError; + case ErrorCode.storagePathLoadFailed: + return S.of(context).errorMessageStoragePathLoadFailed; + case ErrorCode.storagePathCreateFailed: + return S.of(context).errorMessageStoragePathCreateFailed; + case ErrorCode.loadSavedViewsError: + return S.of(context).errorMessageLoadSavedViewsError; + case ErrorCode.createSavedViewError: + return S.of(context).errorMessageCreateSavedViewError; + case ErrorCode.deleteSavedViewError: + return S.of(context).errorMessageDeleteSavedViewError; + case ErrorCode.requestTimedOut: + return S.of(context).errorMessageRequestTimedOut; + default: + return S.of(context).errorMessageUnknonwnError; + } +} diff --git a/lib/core/logic/timeout_client.dart b/lib/core/logic/timeout_client.dart new file mode 100644 index 00000000..a380945d --- /dev/null +++ b/lib/core/logic/timeout_client.dart @@ -0,0 +1,135 @@ +import 'dart:typed_data'; + +import 'dart:convert'; + +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:http/http.dart'; +import 'package:injectable/injectable.dart'; + +/// +/// Convenience class which handles timeout errors. +/// +@Injectable(as: BaseClient) +@Named("timeoutClient") +class TimeoutClient implements BaseClient { + static const Duration requestTimeout = Duration(seconds: 25); + + @override + Future send(BaseRequest request) async { + return getIt().send(request).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ); + } + + @override + void close() { + getIt().close(); + } + + @override + Future delete(Uri url, + {Map? headers, Object? body, Encoding? encoding}) async { + return _handle400Error( + await getIt() + .delete(url, headers: headers, body: body, encoding: encoding) + .timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future get(Uri url, {Map? headers}) async { + return _handle400Error( + await getIt().get(url, headers: headers).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future head(Uri url, {Map? headers}) async { + return _handle400Error( + await getIt().head(url, headers: headers).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future patch(Uri url, + {Map? headers, Object? body, Encoding? encoding}) async { + return _handle400Error( + await getIt() + .patch(url, headers: headers, body: body, encoding: encoding) + .timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future post(Uri url, + {Map? headers, Object? body, Encoding? encoding}) async { + return _handle400Error( + await getIt().post(url, headers: headers, body: body, encoding: encoding).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future put(Uri url, + {Map? headers, Object? body, Encoding? encoding}) async { + return _handle400Error( + await getIt().put(url, headers: headers, body: body, encoding: encoding).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ), + ); + } + + @override + Future read(Uri url, {Map? headers}) async { + return getIt().read(url, headers: headers).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ); + } + + @override + Future readBytes(Uri url, {Map? headers}) { + return getIt().readBytes(url, headers: headers).timeout( + requestTimeout, + onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)), + ); + } + + Response _handle400Error(Response response) { + if (response.statusCode == 400) { + // try to parse contained error message, otherwise return response + final JSON json = jsonDecode(response.body); + final Map errorMessages = {}; + //TODO: This could be simplified, look at error message format of paperless-ngx + for (final entry in json.entries) { + if (entry.value is List) { + errorMessages.putIfAbsent(entry.key, () => (entry.value as List).cast().first); + } else if (entry.value is String) { + errorMessages.putIfAbsent(entry.key, () => entry.value); + } else { + errorMessages.putIfAbsent(entry.key, () => entry.value.toString()); + } + } + throw errorMessages; + } + return response; + } +} diff --git a/lib/core/model/document_processing_status.dart b/lib/core/model/document_processing_status.dart new file mode 100644 index 00000000..7d8730a9 --- /dev/null +++ b/lib/core/model/document_processing_status.dart @@ -0,0 +1,46 @@ +enum ProcessingStatus { starting, working, success, error } + +enum ProcessingMessage { + new_file, + parsing_document, + generating_thumbnail, + parse_date, + save_document, + finished +} + +class DocumentProcessingStatus { + final int currentProgress; + final int? documentId; + final String filename; + final int maxProgress; + final ProcessingMessage message; + final ProcessingStatus status; + final String taskId; + final bool isApproximated; + + static const String UNKNOWN_TASK_ID = "NO_TASK_ID"; + + DocumentProcessingStatus({ + required this.currentProgress, + this.documentId, + required this.filename, + required this.maxProgress, + required this.message, + required this.status, + required this.taskId, + this.isApproximated = false, + }); + + factory DocumentProcessingStatus.fromJson(Map json) { + return DocumentProcessingStatus( + currentProgress: json['current_progress'], + documentId: json['documentId'], + filename: json['filename'], + maxProgress: json['max_progress'], + message: ProcessingMessage.values.byName(json['message']), + status: ProcessingStatus.values.byName(json['status']), + taskId: json['task_id'], + ); + } +} diff --git a/lib/core/model/error_message.dart b/lib/core/model/error_message.dart new file mode 100644 index 00000000..d602c7b4 --- /dev/null +++ b/lib/core/model/error_message.dart @@ -0,0 +1,50 @@ +class ErrorMessage implements Exception { + final ErrorCode code; + final StackTrace? stackTrace; + final int? httpStatusCode; + + const ErrorMessage(this.code, {this.stackTrace, this.httpStatusCode}); + + factory ErrorMessage.unknown() { + return const ErrorMessage(ErrorCode.unknown); + } + + @override + String toString() { + return "ErrorMessage(code: $code${stackTrace != null ? ', stackTrace: ${stackTrace.toString()}' : ''}${httpStatusCode != null ? ', httpStatusCode: $httpStatusCode' : ''})"; + } +} + +enum ErrorCode { + unknown, + authenticationFailed, + notAuthenticated, + documentUploadFailed, + documentUpdateFailed, + documentLoadFailed, + documentDeleteFailed, + documentBulkDeleteFailed, + documentPreviewFailed, + documentAsnQueryFailed, + tagCreateFailed, + tagLoadFailed, + documentTypeCreateFailed, + documentTypeLoadFailed, + correspondentCreateFailed, + correspondentLoadFailed, + scanRemoveFailed, + invalidClientCertificateConfiguration, + biometricsNotSupported, + biometricAuthenticationFailed, + deviceOffline, + serverUnreachable, + similarQueryError, + autocompleteQueryError, + storagePathLoadFailed, + storagePathCreateFailed, + loadSavedViewsError, + createSavedViewError, + deleteSavedViewError, + requestTimedOut, + storagePathAlreadyExists; +} diff --git a/lib/core/service/connectivity_status.service.dart b/lib/core/service/connectivity_status.service.dart new file mode 100644 index 00000000..704fd22a --- /dev/null +++ b/lib/core/service/connectivity_status.service.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:injectable/injectable.dart'; + +abstract class ConnectivityStatusService { + Future isConnectedToInternet(); + Future isServerReachable(String serverAddress); + Stream connectivityChanges(); +} + +@Injectable(as: ConnectivityStatusService) +class ConnectivityStatusServiceImpl implements ConnectivityStatusService { + final Connectivity connectivity; + + ConnectivityStatusServiceImpl(this.connectivity); + + @override + Stream connectivityChanges() { + return connectivity.onConnectivityChanged + .map(_hasActiveInternetConnection) + .asBroadcastStream(); + } + + @override + Future isConnectedToInternet() async { + return _hasActiveInternetConnection( + await (Connectivity().checkConnectivity())); + } + + @override + Future isServerReachable(String serverAddress) async { + try { + final result = await InternetAddress.lookup( + serverAddress.replaceAll(RegExp(r"https?://"), "")); + if (result.isNotEmpty && result.first.rawAddress.isNotEmpty) { + return true; + } else { + return false; + } + } on SocketException catch (_) { + return false; + } + } + + bool _hasActiveInternetConnection(ConnectivityResult conn) { + return conn == ConnectivityResult.mobile || + conn == ConnectivityResult.wifi || + conn == ConnectivityResult.ethernet; + } +} diff --git a/lib/core/service/status.service.dart b/lib/core/service/status.service.dart new file mode 100644 index 00000000..225c414f --- /dev/null +++ b/lib/core/service/status.service.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_paperless_mobile/core/bloc/document_status_cubit.dart'; +import 'package:flutter_paperless_mobile/core/model/document_processing_status.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; +import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:injectable/injectable.dart'; +import 'package:web_socket_channel/io.dart'; + +abstract class StatusService { + Future startListeningBeforeDocumentUpload( + String httpUrl, AuthenticationInformation credentials, String documentFileName); +} + +@Singleton(as: StatusService) +@Named("webSocketStatusService") +class WebSocketStatusService implements StatusService { + late WebSocket? socket; + late IOWebSocketChannel? _channel; + + WebSocketStatusService(); + + @override + Future startListeningBeforeDocumentUpload( + String httpUrl, + AuthenticationInformation credentials, + String documentFileName, + ) async { + socket = await WebSocket.connect( + httpUrl.replaceFirst("http", "ws") + "/ws/status/", + customClient: getIt(), + headers: { + 'Authorization': 'Token ${credentials.token}', + }, + ).catchError((_) { + // Use long polling if connection could not be established + }); + + if (socket != null) { + socket!.where(isNotNull).listen((event) { + final status = DocumentProcessingStatus.fromJson(event); + getIt().updateStatus(status); + if (status.currentProgress == 100) { + socket!.close(); + } + }); + } + } +} + +@Injectable(as: StatusService) +@Named("longPollingStatusService") +class LongPollingStatusService implements StatusService { + static const maxRetries = 60; + + final BaseClient httpClient; + LongPollingStatusService(@Named("timeoutClient") this.httpClient); + + @override + Future startListeningBeforeDocumentUpload( + String httpUrl, + AuthenticationInformation credentials, + String documentFileName, + ) async { + final today = DateTime.now(); + bool consumptionFinished = false; + int retryCount = 0; + + getIt().updateStatus( + DocumentProcessingStatus( + currentProgress: 0, + filename: documentFileName, + maxProgress: 100, + message: ProcessingMessage.new_file, + status: ProcessingStatus.working, + taskId: DocumentProcessingStatus.UNKNOWN_TASK_ID, + documentId: null, + isApproximated: true, + ), + ); + + do { + final response = await httpClient.get( + Uri.parse('$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'), + ); + final data = PagedSearchResult.fromJson(jsonDecode(response.body), DocumentModel.fromJson); + if (data.count > 0) { + consumptionFinished = true; + final docId = data.results[0].id; + getIt().updateStatus( + DocumentProcessingStatus( + currentProgress: 100, + filename: documentFileName, + maxProgress: 100, + message: ProcessingMessage.finished, + status: ProcessingStatus.success, + taskId: DocumentProcessingStatus.UNKNOWN_TASK_ID, + documentId: docId, + isApproximated: true, + ), + ); + return; + } + sleep(const Duration(seconds: 1)); + } while (!consumptionFinished && retryCount < maxRetries); + } +} diff --git a/lib/core/store/local_vault.dart b/lib/core/store/local_vault.dart new file mode 100644 index 00000000..2cf51fac --- /dev/null +++ b/lib/core/store/local_vault.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; +import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart'; +import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class LocalVault { + static const applicationSettingsKey = "applicationSettings"; + static const authenticationKey = "authentication"; + + final EncryptedSharedPreferences sharedPreferences; + + LocalVault(this.sharedPreferences); + + Future storeAuthenticationInformation( + AuthenticationInformation auth, + ) async { + await sharedPreferences.setString( + authenticationKey, + json.encode(auth.toJson()), + ); + } + + Future loadAuthenticationInformation() async { + if ((await sharedPreferences.getString(authenticationKey)).isEmpty) { + return null; + } + return AuthenticationInformation.fromJson( + json.decode(await sharedPreferences.getString(authenticationKey)), + ); + } + + Future loadCertificate() async { + return loadAuthenticationInformation().then((value) => value?.clientCertificate); + } + + Future storeApplicationSettings(ApplicationSettingsState settings) { + return sharedPreferences.setString(applicationSettingsKey, json.encode(settings.toJson())); + } + + Future loadApplicationSettings() async { + final settings = await sharedPreferences.getString(applicationSettingsKey); + if (settings.isEmpty) { + return null; + } + return ApplicationSettingsState.fromJson(json.decode(settings)); + } + + Future clear() { + return sharedPreferences.clear(); + } +} diff --git a/lib/core/type/json.dart b/lib/core/type/json.dart new file mode 100644 index 00000000..3f05d90a --- /dev/null +++ b/lib/core/type/json.dart @@ -0,0 +1 @@ +typedef JSON = Map; diff --git a/lib/core/util.dart b/lib/core/util.dart new file mode 100644 index 00000000..1acfe63e --- /dev/null +++ b/lib/core/util.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_paperless_mobile/core/logic/timeout_client.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:http/http.dart'; +import 'package:path_provider/path_provider.dart'; + +const requestTimeout = Duration(seconds: 5); + +Future getSingleResult( + String url, + T Function(JSON) fromJson, + ErrorCode errorCode, { + int minRequiredApiVersion = 1, +}) async { + final httpClient = getIt(instanceName: "timeoutClient"); + final response = await httpClient.get( + Uri.parse(url), + headers: {'accept': 'application/json; version=$minRequiredApiVersion'}, + ); + if (response.statusCode == 200) { + return fromJson(jsonDecode(utf8.decode(response.bodyBytes)) as JSON); + } + return Future.error(errorCode); +} + +Future> getCollection( + String url, + T Function(JSON) fromJson, + ErrorCode errorCode, { + int minRequiredApiVersion = 1, +}) async { + final httpClient = getIt(instanceName: "timeoutClient"); + final response = await httpClient.get( + Uri.parse(url), + headers: {'accept': 'application/json; version=$minRequiredApiVersion'}, + ); + if (response.statusCode == 200) { + final JSON body = jsonDecode(utf8.decode(response.bodyBytes)); + if (body.containsKey('count')) { + if (body['count'] == 0) { + return []; + } else { + return body['results'].cast().map((result) => fromJson(result)).toList(); + } + } + } + return Future.error(errorCode); +} + +class FileUtils { + static Future saveToFile( + Uint8List bytes, + String filename, { + StorageDirectory directoryType = StorageDirectory.documents, + }) async { + final dir = (await getExternalStorageDirectories(type: directoryType)); + File file = File("$dir/$filename"); + file.writeAsBytesSync(bytes); + return file; + } +} diff --git a/lib/core/widgets/coming_soon_placeholder.dart b/lib/core/widgets/coming_soon_placeholder.dart new file mode 100644 index 00000000..d8fda8a2 --- /dev/null +++ b/lib/core/widgets/coming_soon_placeholder.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class ComingSoon extends StatelessWidget { + const ComingSoon({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + "Coming Soon\u2122", + style: Theme.of(context).textTheme.titleLarge, + ), + ); + } +} diff --git a/lib/core/widgets/confirm_button.dart b/lib/core/widgets/confirm_button.dart new file mode 100644 index 00000000..12e1aa1e --- /dev/null +++ b/lib/core/widgets/confirm_button.dart @@ -0,0 +1,70 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class ElevatedConfirmationButton extends StatefulWidget { + factory ElevatedConfirmationButton.icon(BuildContext context, + {required void Function() onPressed, required Icon icon, required Widget label}) { + final double scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1; + final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!; + return ElevatedConfirmationButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [icon, SizedBox(width: gap), Flexible(child: label)], + ), + onPressed: onPressed, + ); + } + + const ElevatedConfirmationButton({ + Key? key, + this.color, + required this.onPressed, + required this.child, + this.confirmWidget = const Text("Confirm?"), + }) : super(key: key); + + final Color? color; + final void Function()? onPressed; + final Widget child; + final Widget confirmWidget; + @override + State createState() => _ElevatedConfirmationButtonState(); +} + +class _ElevatedConfirmationButtonState extends State { + bool _clickedOnce = false; + double? _originalWidth; + final GlobalKey _originalWidgetKey = GlobalKey(); + @override + Widget build(BuildContext context) { + if (!_clickedOnce) { + return ElevatedButton( + key: _originalWidgetKey, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(widget.color), + ), + onPressed: () { + _originalWidth = + (_originalWidgetKey.currentContext?.findRenderObject() as RenderBox).size.width; + setState(() => _clickedOnce = true); + }, + child: widget.child, + ); + } else { + return Builder(builder: (context) { + return SizedBox( + width: _originalWidth, + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(widget.color), + ), + onPressed: widget.onPressed, + child: widget.confirmWidget, + ), + ); + }); + } + } +} diff --git a/lib/core/widgets/documents_list_loading_widget.dart b/lib/core/widgets/documents_list_loading_widget.dart new file mode 100644 index 00000000..46884a72 --- /dev/null +++ b/lib/core/widgets/documents_list_loading_widget.dart @@ -0,0 +1,86 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class DocumentsListLoadingWidget extends StatelessWidget { + static const tags = [" ", " ", " "]; + static const titleLengths = [double.infinity, 150.0, 200.0]; + static const correspondentLengths = [200.0, 300.0, 150.0]; + static const fontSize = 16.0; + + const DocumentsListLoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: MediaQuery.of(context).size.height, + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Shimmer.fromColors( + baseColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[300]! + : Colors.grey[900]!, + highlightColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[100]! + : Colors.grey[600]!, + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final r = Random(index); + final tagCount = r.nextInt(tags.length + 1); + final correspondentLength = correspondentLengths[ + r.nextInt(correspondentLengths.length - 1)]; + final titleLength = + titleLengths[r.nextInt(titleLengths.length - 1)]; + return ListTile( + isThreeLine: true, + leading: Container( + color: Colors.white, + height: 50, + width: 50, + ), + title: Container( + padding: const EdgeInsets.symmetric(vertical: 2.0), + width: correspondentLength, + height: fontSize, + color: Colors.white, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 2.0), + height: fontSize, + width: titleLength, + color: Colors.white, + ), + Wrap( + spacing: 2.0, + children: List.generate( + tagCount, + (index) => Chip( + label: Text(tags[r.nextInt(tags.length)]), + ), + ), + ), + ], + ), + ), + ); + }, + itemCount: 25, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart new file mode 100644 index 00000000..2b179ed5 --- /dev/null +++ b/lib/core/widgets/empty_state.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class EmptyState extends StatelessWidget { + final String title; + final String subtitle; + final Widget? bottomChild; + + const EmptyState({ + Key? key, + required this.title, + required this.subtitle, + this.bottomChild, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: size.height / 3, + width: size.width / 3, + child: SvgPicture.asset("assets/images/empty-state.svg"), + ), + Column( + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + subtitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + if (bottomChild != null) ...[bottomChild!] else ...[] + ], + ); + } +} diff --git a/lib/core/widgets/expandable_floating_action_button.dart b/lib/core/widgets/expandable_floating_action_button.dart new file mode 100644 index 00000000..3314a2e7 --- /dev/null +++ b/lib/core/widgets/expandable_floating_action_button.dart @@ -0,0 +1,215 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +@immutable +class ExpandableFloatingActionButton extends StatefulWidget { + const ExpandableFloatingActionButton({ + super.key, + this.initialOpen, + required this.distance, + required this.children, + }); + + final bool? initialOpen; + final double distance; + final List children; + + @override + State createState() => + _ExpandableFloatingActionButtonState(); +} + +class _ExpandableFloatingActionButtonState + extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _expandAnimation; + bool _open = false; + + @override + void initState() { + super.initState(); + _open = widget.initialOpen ?? false; + _controller = AnimationController( + value: _open ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + vsync: this, + ); + _expandAnimation = CurvedAnimation( + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.easeOutQuad, + parent: _controller, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() { + _open = !_open; + if (_open) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Stack( + alignment: Alignment.bottomRight, + clipBehavior: Clip.none, + children: [ + _buildTapToCloseFab(), + ..._buildExpandingActionButtons(), + _buildTapToOpenFab(), + ], + ), + ); + } + + Widget _buildTapToCloseFab() { + return SizedBox( + width: 56.0, + height: 56.0, + child: Center( + child: Material( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + elevation: 4.0, + child: InkWell( + onTap: _toggle, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + Icons.close, + color: Theme.of(context).primaryColor, + ), + ), + ), + ), + ), + ); + } + + List _buildExpandingActionButtons() { + final children = []; + final count = widget.children.length; + final step = 90.0 / (count - 1); + for (var i = 0, angleInDegrees = 0.0; + i < count; + i++, angleInDegrees += step) { + children.add( + _ExpandingActionButton( + directionInDegrees: angleInDegrees, + maxDistance: widget.distance, + progress: _expandAnimation, + child: widget.children[i], + ), + ); + } + return children; + } + + Widget _buildTapToOpenFab() { + return IgnorePointer( + ignoring: _open, + child: AnimatedContainer( + transformAlignment: Alignment.center, + transform: Matrix4.diagonal3Values( + _open ? 0.7 : 1.0, + _open ? 0.7 : 1.0, + 1.0, + ), + duration: const Duration(milliseconds: 250), + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + child: AnimatedOpacity( + opacity: _open ? 0.0 : 1.0, + curve: const Interval(0.25, 1.0, curve: Curves.easeInOut), + duration: const Duration(milliseconds: 250), + child: FloatingActionButton( + onPressed: _toggle, + child: const Icon(Icons.create), + ), + ), + ), + ); + } +} + +@immutable +class _ExpandingActionButton extends StatelessWidget { + const _ExpandingActionButton({ + required this.directionInDegrees, + required this.maxDistance, + required this.progress, + required this.child, + }); + + final double directionInDegrees; + final double maxDistance; + final Animation progress; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: progress, + builder: (context, child) { + final offset = Offset.fromDirection( + directionInDegrees * (math.pi / 180.0), + progress.value * maxDistance, + ); + return Positioned( + right: 4.0 + offset.dx, + bottom: 4.0 + offset.dy, + child: Transform.rotate( + angle: (1.0 - progress.value) * math.pi / 2, + child: child!, + ), + ); + }, + child: FadeTransition( + opacity: progress, + child: child, + ), + ); + } +} + +@immutable +class ExpandableActionButton extends StatelessWidget { + const ExpandableActionButton({ + super.key, + this.color, + this.onPressed, + required this.icon, + }); + + final VoidCallback? onPressed; + final Widget icon; + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + width: 48, + child: ElevatedButton( + onPressed: onPressed, + child: icon, + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.all(color), + ), + ), + ); + } +} diff --git a/lib/core/widgets/highlighted_text.dart b/lib/core/widgets/highlighted_text.dart new file mode 100644 index 00000000..e16b6800 --- /dev/null +++ b/lib/core/widgets/highlighted_text.dart @@ -0,0 +1,125 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class HighlightedText extends StatelessWidget { + final String text; + final List highlights; + final Color? color; + final TextStyle? style; + final bool caseSensitive; + + final TextAlign textAlign; + final TextDirection? textDirection; + final TextOverflow overflow; + final double textScaleFactor; + final int? maxLines; + final StrutStyle? strutStyle; + final TextWidthBasis textWidthBasis; + final TextHeightBehavior? textHeightBehavior; + + const HighlightedText({ + super.key, + required this.text, + required this.highlights, + this.style, + this.color = Colors.yellowAccent, + this.caseSensitive = true, + this.textAlign = TextAlign.start, + this.textDirection = TextDirection.ltr, + this.overflow = TextOverflow.clip, + this.textScaleFactor = 1.0, + this.maxLines, + this.strutStyle, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + }); + + @override + Widget build(BuildContext context) { + if (text.isEmpty || highlights.isEmpty || highlights.contains('')) { + return SelectableText.rich( + _normalSpan(text, context), + key: key, + textAlign: textAlign, + textDirection: textDirection, + textScaleFactor: textScaleFactor, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + style: TextStyle(overflow: overflow), + ); + } + + return SelectableText.rich( + TextSpan(children: _buildChildren(context)), + key: key, + textAlign: textAlign, + textDirection: textDirection, + textScaleFactor: textScaleFactor, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + style: TextStyle(overflow: overflow), + ); + } + + List _buildChildren(BuildContext context) { + List _spans = []; + int _start = 0; + + String _text = caseSensitive ? text : text.toLowerCase(); + List _highlights = + caseSensitive ? highlights : highlights.map((e) => e.toLowerCase()).toList(); + + while (true) { + Map _highlightsMap = {}; //key (index), value (highlight). + + for (final h in _highlights) { + final idx = _text.indexOf(h, _start); + if (idx >= 0) { + _highlightsMap.putIfAbsent(_text.indexOf(h, _start), () => h); + } + } + + if (_highlightsMap.isNotEmpty) { + int _currentIndex = _highlightsMap.keys.reduce(min); + String _currentHighlight = text.substring( + _currentIndex, + _currentIndex + _highlightsMap[_currentIndex]!.length, + ); + + if (_currentIndex == _start) { + _spans.add(_highlightSpan(_currentHighlight)); + _start += _currentHighlight.length; + } else { + _spans.add(_normalSpan(text.substring(_start, _currentIndex), context)); + _spans.add(_highlightSpan(_currentHighlight)); + _start = _currentIndex + _currentHighlight.length; + } + } else { + _spans.add(_normalSpan(text.substring(_start, text.length), context)); + break; + } + } + return _spans; + } + + TextSpan _highlightSpan(String value) { + return TextSpan( + text: value, + style: style?.copyWith( + backgroundColor: color, + ), + ); + } + + TextSpan _normalSpan(String value, BuildContext context) { + return TextSpan( + text: value, + style: style ?? Theme.of(context).textTheme.bodyText2, + ); + } +} diff --git a/lib/core/widgets/offline_banner.dart b/lib/core/widgets/offline_banner.dart new file mode 100644 index 00000000..08360140 --- /dev/null +++ b/lib/core/widgets/offline_banner.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class OfflineBanner extends StatelessWidget with PreferredSizeWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).disabledColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Icon( + Icons.cloud_off, + size: 24, + ), + ), + Text(S.of(context).genericMessageOfflineText), + ], + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(24); +} diff --git a/lib/core/widgets/offline_widget.dart b/lib/core/widgets/offline_widget.dart new file mode 100644 index 00000000..a7213160 --- /dev/null +++ b/lib/core/widgets/offline_widget.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class OfflineWidget extends StatelessWidget { + const OfflineWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.mood_bad, size: (Theme.of(context).iconTheme.size ?? 24) * 3), + Text( + S.of(context).offlineWidgetText, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/paperless_logo.dart b/lib/core/widgets/paperless_logo.dart new file mode 100644 index 00000000..dd359b78 --- /dev/null +++ b/lib/core/widgets/paperless_logo.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class PaperlessLogo extends StatelessWidget { + final double? height; + final double? width; + const PaperlessLogo({Key? key, this.height, this.width}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints( + maxHeight: height ?? Theme.of(context).iconTheme.size ?? 32, + maxWidth: width ?? Theme.of(context).iconTheme.size ?? 32, + ), + padding: const EdgeInsets.only(right: 8), + child: SvgPicture.asset( + "assets/logo/paperless_ng_logo_light.svg", + color: Theme.of(context).primaryColor, + ), + ); + } +} diff --git a/lib/di_initializer.dart b/lib/di_initializer.dart new file mode 100644 index 00000000..abed7156 --- /dev/null +++ b/lib/di_initializer.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter_paperless_mobile/di_initializer.config.dart'; +import 'package:flutter_paperless_mobile/di_modules.dart'; +import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; + +final getIt = GetIt.instance..allowReassignment; + +@InjectableInit( + initializerName: r'$initGetIt', // default + preferRelativeImports: true, // default + asExtension: false, // default +) +void configureDependencies() => $initGetIt(getIt); + +/// +/// Registers new security context, which will be used by the HttpClient, see [RegisterModule]. +/// +void registerSecurityContext(ClientCertificate? cert) { + var context = SecurityContext(); + if (cert != null) { + context = context + ..usePrivateKeyBytes(cert.bytes, password: cert.passphrase) + ..useCertificateChainBytes(cert.bytes, password: cert.passphrase) + ..setTrustedCertificatesBytes(cert.bytes, password: cert.passphrase); + } + getIt.unregister(); + getIt.registerSingleton(context); +} diff --git a/lib/di_modules.dart b/lib/di_modules.dart new file mode 100644 index 00000000..3fdcc2b4 --- /dev/null +++ b/lib/di_modules.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_paperless_mobile/core/interceptor/authentication.interceptor.dart'; +import 'package:flutter_paperless_mobile/core/interceptor/connection_state.interceptor.dart'; +import 'package:flutter_paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:flutter_paperless_mobile/core/interceptor/response_conversion.interceptor.dart'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:http_interceptor/http/http.dart'; +import 'package:injectable/injectable.dart'; +import 'package:local_auth/local_auth.dart'; + +@module +abstract class RegisterModule { + @singleton + LocalAuthentication get localAuthentication => LocalAuthentication(); + @singleton + EncryptedSharedPreferences get encryptedSharedPreferences => EncryptedSharedPreferences(); + @singleton + SecurityContext get securityContext => SecurityContext(); + @singleton + Connectivity get connectivity => Connectivity(); + + /// + /// Factory method creating an [HttpClient] with the currently registered [SecurityContext]. + /// + HttpClient getHttpClient(SecurityContext securityContext) => + HttpClient(context: securityContext)..connectionTimeout = const Duration(seconds: 10); + + /// + /// Factory method creating a [InterceptedClient] on top of the currently registered [HttpClient]. + /// + BaseClient getBaseClient( + AuthenticationInterceptor authInterceptor, + ResponseConversionInterceptor responseConversionInterceptor, + ConnectionStateInterceptor connectionStateInterceptor, + LanguageHeaderInterceptor languageHeaderInterceptor, + HttpClient client, + ) => + InterceptedClient.build( + interceptors: [ + authInterceptor, + responseConversionInterceptor, + connectionStateInterceptor, + languageHeaderInterceptor + ], + client: IOClient(client), + ); + + CacheManager getCacheManager(BaseClient client) => + CacheManager(Config('cacheKey', fileService: HttpFileService(httpClient: client))); +} diff --git a/lib/extensions/dart_extensions.dart b/lib/extensions/dart_extensions.dart new file mode 100644 index 00000000..f1555040 --- /dev/null +++ b/lib/extensions/dart_extensions.dart @@ -0,0 +1,9 @@ +extension NullableMapKey on Map { + V? tryPutIfAbsent(K key, V? Function() ifAbsent) { + final value = ifAbsent(); + if (value == null) { + return null; + } + return putIfAbsent(key, () => value); + } +} diff --git a/lib/extensions/flutter_extensions.dart b/lib/extensions/flutter_extensions.dart new file mode 100644 index 00000000..51fb94d0 --- /dev/null +++ b/lib/extensions/flutter_extensions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; + +extension WidgetPadding on Widget { + Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(8)]) { + return Padding( + padding: value, + child: this, + ); + } +} + +extension WidgetsPadding on List { + List padded([EdgeInsetsGeometry value = const EdgeInsets.all(8)]) { + return map((child) => Padding( + padding: value, + child: child, + )).toList(); + } +} diff --git a/lib/features/app_intro/application_intro_slideshow.dart b/lib/features/app_intro/application_intro_slideshow.dart new file mode 100644 index 00000000..f34eb8d8 --- /dev/null +++ b/lib/features/app_intro/application_intro_slideshow.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/app_intro/widgets/biometric_authentication_intro_slide.dart'; +import 'package:flutter_paperless_mobile/features/app_intro/widgets/configuration_done_intro_slide.dart'; +import 'package:flutter_paperless_mobile/features/app_intro/widgets/welcome_intro_slide.dart'; +import 'package:flutter_paperless_mobile/features/home/view/home_page.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:intro_slider/intro_slider.dart'; + +class ApplicationIntroSlideshow extends StatelessWidget { + const ApplicationIntroSlideshow({super.key}); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: IntroSlider( + renderDoneBtn: TextButton( + child: Text("GO"), //TODO: INTL + onPressed: () { + Navigator.pop(context); + }, + ), + backgroundColorAllTabs: Theme.of(context).canvasColor, + onDonePress: () => Navigator.of(context) + .pushReplacement(MaterialPageRoute(builder: (context) => const HomePage())), + listCustomTabs: [ + const WelcomeIntroSlide(), + BlocProvider.value( + value: getIt(), + child: const BiometricAuthenticationIntroSlide(), + ), + const ConfigurationDoneIntroSlide(), + ].padded(const EdgeInsets.all(16.0)), + ), + ); + } +} diff --git a/lib/features/app_intro/widgets/biometric_authentication_intro_slide.dart b/lib/features/app_intro/widgets/biometric_authentication_intro_slide.dart new file mode 100644 index 00000000..06bf214b --- /dev/null +++ b/lib/features/app_intro/widgets/biometric_authentication_intro_slide.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class BiometricAuthenticationIntroSlide extends StatefulWidget { + const BiometricAuthenticationIntroSlide({ + Key? key, + }) : super(key: key); + + @override + State createState() => + _BiometricAuthenticationIntroSlideState(); +} + +class _BiometricAuthenticationIntroSlideState extends State { + @override + Widget build(BuildContext context) { + //TODO: INTL + return BlocBuilder( + builder: (context, settings) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "Configure Biometric Authentication", + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + Text( + "It is highly recommended to additionally secure your local data. Do you want to enable biometric authentication?", + textAlign: TextAlign.center, + ), + Column( + children: [ + const Icon( + Icons.fingerprint, + size: 48, + ), + const SizedBox( + height: 32, + ), + Builder(builder: (context) { + if (settings.isLocalAuthenticationEnabled) { + return ElevatedButton.icon( + icon: Icon( + Icons.done, + color: Colors.green, + ), + label: Text("Enabled"), + onPressed: null, + ); + } + return ElevatedButton( + child: Text("Enable"), + onPressed: () { + final settings = BlocProvider.of(context).state; + getIt() + .authenticateLocalUser("Please authenticate to secure Paperless Mobile") + .then((isEnabled) { + if (!isEnabled) { + showSnackBar(context, + "Could not set up biometric authentication. Please try again or skip for now."); + return; + } + BlocProvider.of(context) + .setIsBiometricAuthenticationEnabled(true); + }); + }, + ); + }), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/app_intro/widgets/configuration_done_intro_slide.dart b/lib/features/app_intro/widgets/configuration_done_intro_slide.dart new file mode 100644 index 00000000..e5c0b953 --- /dev/null +++ b/lib/features/app_intro/widgets/configuration_done_intro_slide.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class ConfigurationDoneIntroSlide extends StatelessWidget { + const ConfigurationDoneIntroSlide({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + //TODO: INTL + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + "All set up!", + style: Theme.of(context).textTheme.titleLarge, + ), + Icon( + Icons.emoji_emotions_outlined, + size: 64, + ), + Text( + "You've successfully configured Paperless Mobile! Press 'GO' to get started managing your documents.", + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/features/app_intro/widgets/welcome_intro_slide.dart b/lib/features/app_intro/widgets/welcome_intro_slide.dart new file mode 100644 index 00000000..5563a2fd --- /dev/null +++ b/lib/features/app_intro/widgets/welcome_intro_slide.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class WelcomeIntroSlide extends StatelessWidget { + const WelcomeIntroSlide({super.key}); + + @override + Widget build(BuildContext context) { + //TODO: INTL + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "Welcome to Paperless Mobile!", + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + Text( + "Manage and add your documents on the go!", + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart new file mode 100644 index 00000000..184acd45 --- /dev/null +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -0,0 +1,130 @@ +import 'dart:typed_data'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class DocumentsCubit extends Cubit { + final DocumentRepository documentRepository; + + DocumentsCubit(this.documentRepository) : super(DocumentsState.initial); + + Future addDocument( + Uint8List bytes, + String fileName, { + required String title, + required void Function(DocumentModel document) onConsumptionFinished, + int? documentType, + int? correspondent, + List? tags, + DateTime? createdAt, + }) async { + await documentRepository.create( + bytes, + fileName, + title: title, + documentType: documentType, + correspondent: correspondent, + tags: tags, + createdAt: createdAt, + ); + // documentRepository + // .waitForConsumptionFinished(fileName, title) + // .then((value) => onConsumptionFinished(value)); + } + + Future removeDocument(DocumentModel document) async { + await documentRepository.delete(document); + return await reloadDocuments(); + } + + Future bulkRemoveDocuments(List documents) async { + await documentRepository.bulkDelete(documents); + return await reloadDocuments(); + } + + Future updateDocument(DocumentModel document) async { + await documentRepository.update(document); + await reloadDocuments(); + } + + Future loadDocuments() async { + final result = await documentRepository.find(state.filter); + emit(DocumentsState( + isLoaded: true, + value: [...state.value, result], + filter: state.filter, + )); + } + + Future reloadDocuments() async { + if (state.currentPageNumber >= 5) { + return _bulkReloadDocuments(); + } + var newPages = []; + for (final page in state.value) { + final result = await documentRepository.find(state.filter.copyWith(page: page.pageKey)); + newPages.add(result); + } + emit(DocumentsState(isLoaded: true, value: newPages, filter: state.filter)); + } + + Future _bulkReloadDocuments() async { + final result = await documentRepository + .find(state.filter.copyWith(page: 1, pageSize: state.documents.length)); + emit(DocumentsState(isLoaded: true, value: [result], filter: state.filter)); + } + + Future loadMore() async { + if (state.isLastPageLoaded) { + return; + } + final newFilter = state.filter.copyWith(page: state.filter.page + 1); + final result = await documentRepository.find(newFilter); + emit(DocumentsState(isLoaded: true, value: [...state.value, result], filter: newFilter)); + } + + Future assignAsn(DocumentModel document) async { + if (document.archiveSerialNumber == null) { + final int asn = await documentRepository.findNextAsn(); + updateDocument(document.copyWith(archiveSerialNumber: asn)); + } + } + + /// + /// Update filter state and automatically reload documents. Always resets page to 1. + /// Use [DocumentsCubit.loadMore] to load more data. + Future updateFilter({ + DocumentFilter filter = DocumentFilter.initial, + }) async { + final result = await documentRepository.find(filter.copyWith(page: 1)); + emit(DocumentsState(filter: filter, value: [result], isLoaded: true)); + } + + void toggleDocumentSelection(DocumentModel model) { + if (state.selection.contains(model)) { + emit( + state.copyWith( + selection: state.selection.where((element) => element.id != model.id).toList(), + ), + ); + } else { + emit( + state.copyWith(selection: [...state.selection, model]), + ); + } + } + + void resetSelection() { + emit(state.copyWith(selection: [])); + } + + void reset() { + emit(DocumentsState.initial); + } +} diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart new file mode 100644 index 00000000..e02bf963 --- /dev/null +++ b/lib/features/documents/bloc/documents_state.dart @@ -0,0 +1,82 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; + +class DocumentsState extends Equatable { + final bool isLoaded; + final DocumentFilter filter; + final List value; + final List selection; + + const DocumentsState({ + required this.isLoaded, + required this.value, + required this.filter, + this.selection = const [], + }); + + static const DocumentsState initial = DocumentsState( + isLoaded: false, + value: [], + filter: DocumentFilter.initial, + selection: [], + ); + + int get currentPageNumber { + return filter.page; + } + + int? get nextPageNumber { + return isLastPageLoaded ? null : currentPageNumber + 1; + } + + int get count { + if (value.isEmpty) { + return 0; + } + return value.first.count; + } + + bool get isLastPageLoaded { + if (!isLoaded) { + return false; + } + if (value.isNotEmpty) { + return value.last.next == null; + } + return true; + } + + int inferPageCount({required int pageSize}) { + if (!isLoaded) { + return 100000; + } + if (value.isEmpty) { + return 0; + } + return value.first.inferPageCount(pageSize: pageSize); + } + + List get documents { + return value.fold([], (previousValue, element) => [...previousValue, ...element.results]); + } + + DocumentsState copyWith({ + bool overwrite = false, + bool? isLoaded, + List? value, + DocumentFilter? filter, + List? selection, + }) { + return DocumentsState( + isLoaded: isLoaded ?? this.isLoaded, + value: value ?? this.value, + filter: filter ?? this.filter, + selection: selection ?? this.selection, + ); + } + + @override + List get props => [isLoaded, filter, value, selection]; +} diff --git a/lib/features/documents/bloc/saved_view_cubit.dart b/lib/features/documents/bloc/saved_view_cubit.dart new file mode 100644 index 00000000..a3dd1bcd --- /dev/null +++ b/lib/features/documents/bloc/saved_view_cubit.dart @@ -0,0 +1,50 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/saved_views_repository.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class SavedViewCubit extends Cubit { + SavedViewCubit() : super(SavedViewState(value: {})); + + void selectView(SavedView? view) { + emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id)); + } + + Future add(SavedView view) async { + final savedView = await getIt().save(view); + emit( + SavedViewState( + value: {...state.value, savedView.id!: savedView}, + selectedSavedViewId: state.selectedSavedViewId, + ), + ); + return savedView; + } + + Future remove(SavedView view) async { + final id = await getIt().delete(view); + final newValue = {...state.value}; + newValue.removeWhere((key, value) => key == id); + emit( + SavedViewState( + value: newValue, + selectedSavedViewId: + view.id == state.selectedSavedViewId ? null : state.selectedSavedViewId, + ), + ); + return id; + } + + Future initialize() async { + final views = await getIt().getAll(); + final values = {for (var element in views) element.id!: element}; + emit(SavedViewState(value: values)); + } + + void resetSelection() { + emit(SavedViewState(value: state.value)); + } +} diff --git a/lib/features/documents/bloc/saved_view_state.dart b/lib/features/documents/bloc/saved_view_state.dart new file mode 100644 index 00000000..8f009f50 --- /dev/null +++ b/lib/features/documents/bloc/saved_view_state.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; + +class SavedViewState with EquatableMixin { + final Map value; + final int? selectedSavedViewId; + + SavedViewState({ + required this.value, + this.selectedSavedViewId, + }); + + @override + List get props => [value, selectedSavedViewId]; +} diff --git a/lib/features/documents/model/bulk_edit.model.dart b/lib/features/documents/model/bulk_edit.model.dart new file mode 100644 index 00000000..9fb7d656 --- /dev/null +++ b/lib/features/documents/model/bulk_edit.model.dart @@ -0,0 +1,19 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; + +class BulkEditAction { + final List documents; + final String _method; + final Map parameters; + + BulkEditAction.delete(this.documents) + : _method = 'delete', + parameters = {}; + + JSON toJson() { + return { + 'documents': documents, + 'method': _method, + 'parameters': parameters, + }; + } +} diff --git a/lib/features/documents/model/document.model.dart b/lib/features/documents/model/document.model.dart new file mode 100644 index 00000000..8eae4f1d --- /dev/null +++ b/lib/features/documents/model/document.model.dart @@ -0,0 +1,148 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart'; + +class DocumentModel extends Equatable { + static const idKey = 'id'; + static const titleKey = "title"; + static const contentKey = "content"; + static const archivedFileNameKey = "archived_file_name"; + static const asnKey = "archive_serial_number"; + static const createdKey = "created"; + static const modifiedKey = "modified"; + static const addedKey = "added"; + static const correspondentKey = "correspondent"; + static const originalFileNameKey = 'original_file_name'; + static const documentTypeKey = "document_type"; + static const tagsKey = "tags"; + static const storagePathKey = "storage_path"; + + final int id; + final String title; + final String? content; + final List tags; + final int? documentType; + final int? correspondent; + final int? storagePath; + final DateTime created; + final DateTime modified; + final DateTime added; + final int? archiveSerialNumber; + final String originalFileName; + final String? archivedFileName; + + const DocumentModel({ + required this.id, + required this.title, + this.content, + this.tags = const [], + required this.documentType, + required this.correspondent, + required this.created, + required this.modified, + required this.added, + this.archiveSerialNumber, + required this.originalFileName, + this.archivedFileName, + this.storagePath, + }); + + DocumentModel.fromJson(JSON json) + : id = json[idKey], + title = json[titleKey], + content = json[contentKey], + created = DateTime.parse(json[createdKey]), + modified = DateTime.parse(json[modifiedKey]), + added = DateTime.parse(json[addedKey]), + archiveSerialNumber = json[asnKey], + originalFileName = json[originalFileNameKey], + archivedFileName = json[archivedFileNameKey], + tags = (json[tagsKey] as List).cast(), + correspondent = json[correspondentKey], + documentType = json[documentTypeKey], + storagePath = json[storagePathKey]; + + JSON toJson() { + return { + idKey: id, + titleKey: title, + asnKey: archiveSerialNumber, + archivedFileNameKey: archivedFileName, + contentKey: content, + correspondentKey: correspondent, + documentTypeKey: documentType, + createdKey: created.toUtc().toIso8601String(), + modifiedKey: modified.toUtc().toIso8601String(), + addedKey: added.toUtc().toIso8601String(), + originalFileNameKey: originalFileName, + tagsKey: tags, + storagePathKey: storagePath, + }; + } + + DocumentModel copyWith({ + String? title, + String? content, + IdsQueryParameter? tags, + IdQueryParameter? documentType, + IdQueryParameter? correspondent, + IdQueryParameter? storagePath, + DateTime? created, + DateTime? modified, + DateTime? added, + int? archiveSerialNumber, + String? originalFileName, + String? archivedFileName, + }) { + return DocumentModel( + id: id, + title: title ?? this.title, + content: content ?? this.content, + documentType: fromQuery(documentType, this.documentType), + correspondent: fromQuery(correspondent, this.correspondent), + storagePath: fromQuery(storagePath, this.storagePath), + tags: fromListQuery(tags, this.tags), + created: created ?? this.created, + modified: modified ?? this.modified, + added: added ?? this.added, + originalFileName: originalFileName ?? this.originalFileName, + archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber, + archivedFileName: archivedFileName ?? this.archivedFileName, + ); + } + + int? fromQuery(IdQueryParameter? query, int? previous) { + if (query == null) { + return previous; + } + return query.id; + } + + List fromListQuery(IdsQueryParameter? query, List previous) { + if (query == null) { + return previous; + } + return query.ids; + } + + @override + List get props => [ + id, + title, + content, + tags, + documentType, + storagePath, + correspondent, + created, + modified, + added, + archiveSerialNumber, + originalFileName, + archivedFileName, + storagePath + ]; +} diff --git a/lib/features/documents/model/document_filter.dart b/lib/features/documents/model/document_filter.dart new file mode 100644 index 00000000..5180d8b7 --- /dev/null +++ b/lib/features/documents/model/document_filter.dart @@ -0,0 +1,168 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/asn_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class DocumentFilter with EquatableMixin { + static const DocumentFilter initial = DocumentFilter(); + + static const DocumentFilter latestDocument = DocumentFilter( + sortField: SortField.added, + sortOrder: SortOrder.descending, + pageSize: 1, + page: 1, + ); + + final int pageSize; + final int page; + final DocumentTypeQuery documentType; + final CorrespondentQuery correspondent; + final StoragePathQuery storagePath; + final AsnQuery asn; + final TagsQuery tags; + final SortField sortField; + final SortOrder sortOrder; + final DateTime? addedDateAfter; + final DateTime? addedDateBefore; + final DateTime? createdDateAfter; + final DateTime? createdDateBefore; + final QueryType queryType; + final String? queryText; + + const DocumentFilter({ + this.createdDateAfter, + this.createdDateBefore, + this.documentType = const DocumentTypeQuery.unset(), + this.correspondent = const CorrespondentQuery.unset(), + this.storagePath = const StoragePathQuery.unset(), + this.asn = const AsnQuery.unset(), + this.tags = const TagsQuery.unset(), + this.sortField = SortField.created, + this.sortOrder = SortOrder.descending, + this.page = 1, + this.pageSize = 25, + this.addedDateAfter, + this.addedDateBefore, + this.queryType = QueryType.titleAndContent, + this.queryText, + }); + + String toQueryString() { + final StringBuffer sb = StringBuffer("page=$page&page_size=$pageSize"); + sb.write(documentType.toQueryParameter()); + sb.write(correspondent.toQueryParameter()); + sb.write(tags.toQueryParameter()); + sb.write(storagePath.toQueryParameter()); + sb.write(asn.toQueryParameter()); + + if (queryText?.isNotEmpty ?? false) { + sb.write("&${queryType.queryParam}=$queryText"); + } + + sb.write("&ordering=${sortOrder.queryString}${sortField.queryString}"); + + if (addedDateAfter != null) { + sb.write("&added__date__gt=${dateFormat.format(addedDateAfter!)}"); + } + + if (addedDateBefore != null) { + sb.write("&added__date__lt=${dateFormat.format(addedDateBefore!)}"); + } + + if (createdDateAfter != null) { + sb.write("&created__date__gt=${dateFormat.format(createdDateAfter!)}"); + } + + if (createdDateBefore != null) { + sb.write("&created__date__lt=${dateFormat.format(createdDateBefore!)}"); + } + + return sb.toString(); + } + + @override + String toString() { + return toQueryString(); + } + + DocumentFilter copyWith({ + int? pageSize, + int? page, + bool? onlyNoDocumentType, + DocumentTypeQuery? documentType, + CorrespondentQuery? correspondent, + StoragePathQuery? storagePath, + TagsQuery? tags, + SortField? sortField, + SortOrder? sortOrder, + DateTime? addedDateAfter, + DateTime? addedDateBefore, + DateTime? createdDateBefore, + DateTime? createdDateAfter, + QueryType? queryType, + String? queryText, + }) { + return DocumentFilter( + pageSize: pageSize ?? this.pageSize, + page: page ?? this.page, + documentType: documentType ?? this.documentType, + correspondent: correspondent ?? this.correspondent, + storagePath: storagePath ?? this.storagePath, + tags: tags ?? this.tags, + sortField: sortField ?? this.sortField, + sortOrder: sortOrder ?? this.sortOrder, + addedDateAfter: addedDateAfter ?? this.addedDateAfter, + addedDateBefore: addedDateBefore ?? this.addedDateBefore, + queryType: queryType ?? this.queryType, + queryText: queryText ?? this.queryText, + createdDateBefore: createdDateBefore ?? this.createdDateBefore, + createdDateAfter: createdDateAfter ?? this.createdDateAfter, + ); + } + + String? get titleOnlyMatchString { + if (queryType == QueryType.title) { + return queryText?.isEmpty ?? true ? null : queryText; + } + return null; + } + + String? get titleAndContentMatchString { + if (queryType == QueryType.titleAndContent) { + return queryText?.isEmpty ?? true ? null : queryText; + } + return null; + } + + String? get extendedMatchString { + if (queryType == QueryType.extended) { + return queryText?.isEmpty ?? true ? null : queryText; + } + return null; + } + + @override + List get props => [ + pageSize, + page, + documentType, + correspondent, + storagePath, + asn, + tags, + sortField, + sortOrder, + addedDateAfter, + addedDateBefore, + createdDateAfter, + createdDateBefore, + queryType, + queryText, + ]; +} diff --git a/lib/features/documents/model/document_meta_data.model.dart b/lib/features/documents/model/document_meta_data.model.dart new file mode 100644 index 00000000..02ef6be4 --- /dev/null +++ b/lib/features/documents/model/document_meta_data.model.dart @@ -0,0 +1,40 @@ +class DocumentMetaData { + String originalChecksum; + int originalSize; + String originalMimeType; + String mediaFilename; + bool hasArchiveVersion; + String? archiveChecksum; + int? archiveSize; + + DocumentMetaData({ + required this.originalChecksum, + required this.originalSize, + required this.originalMimeType, + required this.mediaFilename, + required this.hasArchiveVersion, + this.archiveChecksum, + this.archiveSize, + }); + + DocumentMetaData.fromJson(Map json) + : originalChecksum = json['original_checksum'], + originalSize = json['original_size'], + originalMimeType = json['original_mime_type'], + mediaFilename = json['media_filename'], + hasArchiveVersion = json['has_archive_version'], + archiveChecksum = json['archive_checksum'], + archiveSize = json['archive_size']; + + Map toJson() { + final data = {}; + data['original_checksum'] = originalChecksum; + data['original_size'] = originalSize; + data['original_mime_type'] = originalMimeType; + data['media_filename'] = mediaFilename; + data['has_archive_version'] = hasArchiveVersion; + data['archive_checksum'] = archiveChecksum; + data['archive_size'] = archiveSize; + return data; + } +} diff --git a/lib/features/documents/model/filter_rule.model.dart b/lib/features/documents/model/filter_rule.model.dart new file mode 100644 index 00000000..145717b2 --- /dev/null +++ b/lib/features/documents/model/filter_rule.model.dart @@ -0,0 +1,166 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class FilterRule with EquatableMixin { + static const int titleRule = 0; + static const int asnRule = 2; + static const int correspondentRule = 3; + static const int documentTypeRule = 4; + static const int tagRule = 6; + static const int createdBeforeRule = 8; + static const int createdAfterRule = 9; + static const int addedBeforeRule = 13; + static const int addedAfterRule = 14; + static const int titleAndContentRule = 19; + static const int extendedRule = 20; + static const int storagePathRule = 25; + // Currently unsupported view optiosn: + static const int _content = 1; + static const int _isInInbox = 5; + static const int _hasAnyTag = 7; + static const int _createdYearIs = 10; + static const int _createdMonthIs = 11; + static const int _createdDayIs = 12; + static const int _modifiedBefore = 15; + static const int _modifiedAfter = 16; + static const int _doesNotHaveTag = 17; + static const int _doesNotHaveAsn = 18; + static const int _moreLikeThis = 21; + static const int _hasTagsIn = 22; + static const int _asnGreaterThan = 23; + static const int _asnLessThan = 24; + + final int ruleType; + final String? value; + + FilterRule(this.ruleType, this.value); + + FilterRule.fromJson(JSON json) + : ruleType = json['rule_type'], + value = json['value']; + + JSON toJson() { + return { + 'rule_type': ruleType, + 'value': value, + }; + } + + DocumentFilter applyToFilter(final DocumentFilter filter) { + //TODO: Check in profiling mode if this is inefficient enough to cause stutters... + switch (ruleType) { + case titleRule: + return filter.copyWith(queryText: value, queryType: QueryType.title); + case documentTypeRule: + return filter.copyWith( + documentType: value == null + ? const DocumentTypeQuery.notAssigned() + : DocumentTypeQuery.fromId(int.parse(value!)), + ); + case correspondentRule: + return filter.copyWith( + correspondent: value == null + ? const CorrespondentQuery.notAssigned() + : CorrespondentQuery.fromId(int.parse(value!)), + ); + case storagePathRule: + return filter.copyWith( + storagePath: value == null + ? const StoragePathQuery.notAssigned() + : StoragePathQuery.fromId(int.parse(value!)), + ); + case tagRule: + return filter.copyWith( + tags: value == null + ? const TagsQuery.notAssigned() + : TagsQuery.fromIds([...filter.tags.ids, int.parse(value!)]), + ); + case createdBeforeRule: + return filter.copyWith(createdDateBefore: value == null ? null : DateTime.parse(value!)); + case createdAfterRule: + return filter.copyWith(createdDateAfter: value == null ? null : DateTime.parse(value!)); + case addedBeforeRule: + return filter.copyWith(addedDateBefore: value == null ? null : DateTime.parse(value!)); + case addedAfterRule: + return filter.copyWith(addedDateAfter: value == null ? null : DateTime.parse(value!)); + case titleAndContentRule: + return filter.copyWith(queryText: value, queryType: QueryType.titleAndContent); + case extendedRule: + return filter.copyWith(queryText: value, queryType: QueryType.extended); + //TODO: Add currently unused rules + default: + return filter; + } + } + + /// + /// Converts a [DocumentFilter] to a list of [FilterRule]s. + /// + static List fromFilter(final DocumentFilter filter) { + List filterRules = []; + if (filter.correspondent.onlyNotAssigned) { + filterRules.add(FilterRule(correspondentRule, null)); + } + if (filter.correspondent.isSet) { + filterRules.add(FilterRule(correspondentRule, filter.correspondent.id!.toString())); + } + if (filter.documentType.onlyNotAssigned) { + filterRules.add(FilterRule(documentTypeRule, null)); + } + if (filter.documentType.isSet) { + filterRules.add(FilterRule(documentTypeRule, filter.documentType.id!.toString())); + } + if (filter.storagePath.onlyNotAssigned) { + filterRules.add(FilterRule(storagePathRule, null)); + } + if (filter.storagePath.isSet) { + filterRules.add(FilterRule(storagePathRule, filter.storagePath.id!.toString())); + } + if (filter.tags.onlyNotAssigned) { + filterRules.add(FilterRule(tagRule, null)); + } + if (filter.tags.isSet) { + filterRules.addAll(filter.tags.ids.map((id) => FilterRule(tagRule, id.toString()))); + } + + if (filter.queryText != null) { + switch (filter.queryType) { + case QueryType.title: + filterRules.add(FilterRule(titleRule, filter.queryText!)); + break; + case QueryType.titleAndContent: + filterRules.add(FilterRule(titleAndContentRule, filter.queryText!)); + break; + case QueryType.extended: + filterRules.add(FilterRule(extendedRule, filter.queryText!)); + break; + case QueryType.asn: + filterRules.add(FilterRule(asnRule, filter.queryText!)); + break; + } + } + if (filter.createdDateAfter != null) { + filterRules.add(FilterRule(createdAfterRule, dateFormat.format(filter.createdDateAfter!))); + } + if (filter.createdDateBefore != null) { + filterRules.add(FilterRule(createdBeforeRule, dateFormat.format(filter.createdDateBefore!))); + } + if (filter.addedDateAfter != null) { + filterRules.add(FilterRule(addedAfterRule, dateFormat.format(filter.addedDateAfter!))); + } + if (filter.addedDateBefore != null) { + filterRules.add(FilterRule(addedBeforeRule, dateFormat.format(filter.addedDateBefore!))); + } + return filterRules; + } + + @override + List get props => [ruleType, value]; +} diff --git a/lib/features/documents/model/paged_search_result.dart b/lib/features/documents/model/paged_search_result.dart new file mode 100644 index 00000000..dfe625b8 --- /dev/null +++ b/lib/features/documents/model/paged_search_result.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; + +const pageRegex = r".*page=(\d+).*"; + +class PagedSearchResult extends Equatable { + /// Total number of available items + final int count; + + /// Link to next page + final String? next; + + /// Link to previous page + final String? previous; + + /// Actual items + final List results; + + int get pageKey { + if (next != null) { + final matches = RegExp(pageRegex).allMatches(next!); + final group = matches.first.group(1)!; + final nextPageKey = int.parse(group); + return nextPageKey - 1; + } + if (previous != null) { + // This is only executed if it's the last page or there is no data. + final matches = RegExp(pageRegex).allMatches(previous!); + if (matches.isEmpty) { + //In case there is a match but a page is not explicitly set, the page is 1 per default. Therefore, if the previous page is 1, this page is 1+1=2 + return 2; + } + final group = matches.first.group(1)!; + final previousPageKey = int.parse(group); + return previousPageKey + 1; + } + return 1; + } + + const PagedSearchResult({ + required this.count, + required this.next, + required this.previous, + required this.results, + }); + + factory PagedSearchResult.fromJson(Map json, T Function(JSON) fromJson) { + return PagedSearchResult( + count: json['count'], + next: json['next'], + previous: json['previous'], + results: List.from(json['results']).map(fromJson).toList(), + ); + } + + PagedSearchResult copyWith({ + int? count, + String? next, + String? previous, + List? results, + }) { + return PagedSearchResult( + count: count ?? this.count, + next: next ?? this.next, + previous: previous ?? this.previous, + results: results ?? this.results, + ); + } + + /// + /// Returns the number of pages based on the given [pageSize]. The last page + /// might not exhaust its capacity. + /// + int inferPageCount({required int pageSize}) { + if (pageSize == 0) { + return 0; + } + return (count / pageSize).round() + 1; + } + + @override + List get props => [count, next, previous, results]; +} diff --git a/lib/features/documents/model/query_parameters/asn_query.dart b/lib/features/documents/model/query_parameters/asn_query.dart new file mode 100644 index 00000000..364bb8d7 --- /dev/null +++ b/lib/features/documents/model/query_parameters/asn_query.dart @@ -0,0 +1,10 @@ +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; + +class AsnQuery extends IdQueryParameter { + const AsnQuery.fromId(super.id) : super.fromId(); + const AsnQuery.unset() : super.unset(); + const AsnQuery.notAssigned() : super.notAssigned(); + + @override + String get queryParameterKey => 'archive_serial_number'; +} diff --git a/lib/features/documents/model/query_parameters/correspondent_query.dart b/lib/features/documents/model/query_parameters/correspondent_query.dart new file mode 100644 index 00000000..2704fc73 --- /dev/null +++ b/lib/features/documents/model/query_parameters/correspondent_query.dart @@ -0,0 +1,10 @@ +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; + +class CorrespondentQuery extends IdQueryParameter { + const CorrespondentQuery.fromId(super.id) : super.fromId(); + const CorrespondentQuery.unset() : super.unset(); + const CorrespondentQuery.notAssigned() : super.notAssigned(); + + @override + String get queryParameterKey => 'correspondent'; +} diff --git a/lib/features/documents/model/query_parameters/document_type_query.dart b/lib/features/documents/model/query_parameters/document_type_query.dart new file mode 100644 index 00000000..874324e8 --- /dev/null +++ b/lib/features/documents/model/query_parameters/document_type_query.dart @@ -0,0 +1,10 @@ +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; + +class DocumentTypeQuery extends IdQueryParameter { + const DocumentTypeQuery.fromId(super.id) : super.fromId(); + const DocumentTypeQuery.unset() : super.unset(); + const DocumentTypeQuery.notAssigned() : super.notAssigned(); + + @override + String get queryParameterKey => 'document_type'; +} diff --git a/lib/features/documents/model/query_parameters/id_query_parameter.dart b/lib/features/documents/model/query_parameters/id_query_parameter.dart new file mode 100644 index 00000000..2348b89f --- /dev/null +++ b/lib/features/documents/model/query_parameters/id_query_parameter.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; + +abstract class IdQueryParameter extends Equatable { + final bool _onlyNotAssigned; + final int? _id; + + const IdQueryParameter.notAssigned() + : _onlyNotAssigned = true, + _id = null; + + const IdQueryParameter.fromId(int? id) + : _onlyNotAssigned = false, + _id = id; + + const IdQueryParameter.unset() : this.fromId(null); + + bool get isUnset => _id == null && _onlyNotAssigned == false; + + bool get isSet => _id != null && _onlyNotAssigned == false; + + bool get onlyNotAssigned => _onlyNotAssigned; + + int? get id => _id; + + @protected + String get queryParameterKey; + + String toQueryParameter() { + if (onlyNotAssigned) { + return "&${queryParameterKey}__isnull=1"; + } + + return isUnset ? "" : "&${queryParameterKey}__id=$id"; + } + + @override + List get props => [_onlyNotAssigned, _id]; +} diff --git a/lib/features/documents/model/query_parameters/ids_query_parameter.dart b/lib/features/documents/model/query_parameters/ids_query_parameter.dart new file mode 100644 index 00000000..ae3e56c0 --- /dev/null +++ b/lib/features/documents/model/query_parameters/ids_query_parameter.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +abstract class IdsQueryParameter with EquatableMixin { + final List _ids; + final bool onlyNotAssigned; + + const IdsQueryParameter.fromIds(List ids) + : onlyNotAssigned = false, + _ids = ids; + + const IdsQueryParameter.notAssigned() + : onlyNotAssigned = true, + _ids = const []; + + const IdsQueryParameter.unset() + : onlyNotAssigned = false, + _ids = const []; + + bool get isUnset => _ids.isEmpty && onlyNotAssigned == false; + + bool get isSet => _ids.isNotEmpty && onlyNotAssigned == false; + + List get ids => _ids; + + String toQueryParameter(); + + @override + List get props => [onlyNotAssigned, _ids]; +} diff --git a/lib/features/documents/model/query_parameters/query_type.dart b/lib/features/documents/model/query_parameters/query_type.dart new file mode 100644 index 00000000..4216a89f --- /dev/null +++ b/lib/features/documents/model/query_parameters/query_type.dart @@ -0,0 +1,9 @@ +enum QueryType { + title('title__icontains'), + titleAndContent('title_content'), + extended('query'), + asn('asn'); + + final String queryParam; + const QueryType(this.queryParam); +} diff --git a/lib/features/documents/model/query_parameters/sort_field.dart b/lib/features/documents/model/query_parameters/sort_field.dart new file mode 100644 index 00000000..dfb9a8b3 --- /dev/null +++ b/lib/features/documents/model/query_parameters/sort_field.dart @@ -0,0 +1,18 @@ +enum SortField { + archiveSerialNumber("archive_serial_number"), + correspondentName("correspondent__name"), + title("title"), + documentType("documentType"), + created("created"), + added("added"), + modified("modified"); + + final String queryString; + + const SortField(this.queryString); + + @override + String toString() { + return name.toLowerCase(); + } +} diff --git a/lib/features/documents/model/query_parameters/sort_order.dart b/lib/features/documents/model/query_parameters/sort_order.dart new file mode 100644 index 00000000..3962b305 --- /dev/null +++ b/lib/features/documents/model/query_parameters/sort_order.dart @@ -0,0 +1,11 @@ +enum SortOrder { + ascending(""), + descending("-"); + + final String queryString; + const SortOrder(this.queryString); + + SortOrder toggle() { + return this == ascending ? descending : ascending; + } +} diff --git a/lib/features/documents/model/query_parameters/storage_path_query.dart b/lib/features/documents/model/query_parameters/storage_path_query.dart new file mode 100644 index 00000000..3f703a12 --- /dev/null +++ b/lib/features/documents/model/query_parameters/storage_path_query.dart @@ -0,0 +1,10 @@ +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; + +class StoragePathQuery extends IdQueryParameter { + const StoragePathQuery.fromId(super.id) : super.fromId(); + const StoragePathQuery.unset() : super.unset(); + const StoragePathQuery.notAssigned() : super.notAssigned(); + + @override + String get queryParameterKey => 'storage_path'; +} diff --git a/lib/features/documents/model/query_parameters/tags_query.dart b/lib/features/documents/model/query_parameters/tags_query.dart new file mode 100644 index 00000000..0ea7a959 --- /dev/null +++ b/lib/features/documents/model/query_parameters/tags_query.dart @@ -0,0 +1,15 @@ +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart'; + +class TagsQuery extends IdsQueryParameter { + const TagsQuery.fromIds(super.ids) : super.fromIds(); + const TagsQuery.unset() : super.unset(); + const TagsQuery.notAssigned() : super.notAssigned(); + + @override + String toQueryParameter() { + if (onlyNotAssigned) { + return '&is_tagged=false'; + } + return isUnset ? "" : '&tags__id__all=${ids.join(',')}'; + } +} diff --git a/lib/features/documents/model/saved_view.model.dart b/lib/features/documents/model/saved_view.model.dart new file mode 100644 index 00000000..90694536 --- /dev/null +++ b/lib/features/documents/model/saved_view.model.dart @@ -0,0 +1,88 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/filter_rule.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; + +class SavedView with EquatableMixin { + final int? id; + final String name; + + final bool showOnDashboard; + final bool showInSidebar; + + final SortField sortField; + final bool sortReverse; + final List filterRules; + + SavedView({ + this.id, + required this.name, + required this.showOnDashboard, + required this.showInSidebar, + required this.sortField, + required this.sortReverse, + required this.filterRules, + }) { + filterRules.sort( + (a, b) => (a.ruleType.compareTo(b.ruleType) != 0 + ? a.ruleType.compareTo(b.ruleType) + : a.value?.compareTo(b.value ?? "") ?? -1), + ); + } + + @override + List get props => + [name, showOnDashboard, showInSidebar, sortField, sortReverse, filterRules]; + + SavedView.fromJson(JSON json) + : this( + id: json['id'], + name: json['name'], + showOnDashboard: json['show_on_dashboard'], + showInSidebar: json['show_in_sidebar'], + sortField: + SortField.values.where((order) => order.queryString == json['sort_field']).first, + sortReverse: json['sort_reverse'], + filterRules: + json['filter_rules'].cast().map(FilterRule.fromJson).toList(), + ); + + DocumentFilter toDocumentFilter() { + return filterRules.fold( + DocumentFilter( + sortOrder: sortReverse ? SortOrder.ascending : SortOrder.descending, + sortField: sortField, + ), + (filter, filterRule) => filterRule.applyToFilter(filter), + ); + } + + SavedView.fromDocumentFilter( + DocumentFilter filter, { + required String name, + required bool showInSidebar, + required bool showOnDashboard, + }) : this( + id: null, + name: name, + filterRules: FilterRule.fromFilter(filter), + sortField: filter.sortField, + showInSidebar: showInSidebar, + showOnDashboard: showOnDashboard, + sortReverse: filter.sortOrder == SortOrder.ascending, + ); + + JSON toJson() { + return { + 'id': id, + 'name': name, + 'show_on_dashboard': showOnDashboard, + 'show_in_sidebar': showInSidebar, + 'sort_reverse': sortReverse, + 'sort_field': sortField.queryString, + 'filter_rules': filterRules.map((rule) => rule.toJson()).toList(), + }; + } +} diff --git a/lib/features/documents/model/similar_document.model.dart b/lib/features/documents/model/similar_document.model.dart new file mode 100644 index 00000000..0679ee16 --- /dev/null +++ b/lib/features/documents/model/similar_document.model.dart @@ -0,0 +1,59 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; + +class SimilarDocumentModel extends DocumentModel { + final SearchHit searchHit; + + const SimilarDocumentModel({ + required super.id, + required super.title, + required super.documentType, + required super.correspondent, + required super.created, + required super.modified, + required super.added, + required super.originalFileName, + required this.searchHit, + super.archiveSerialNumber, + super.archivedFileName, + super.content, + super.storagePath, + super.tags, + }); + + @override + JSON toJson() { + final json = super.toJson(); + json['__search_hit__'] = searchHit.toJson(); + return json; + } + + SimilarDocumentModel.fromJson(JSON json) + : searchHit = SearchHit.fromJson(json), + super.fromJson(json); +} + +class SearchHit { + final double? score; + final String? highlights; + final int? rank; + + SearchHit({ + this.score, + required this.highlights, + required this.rank, + }); + + JSON toJson() { + return { + 'score': score, + 'highlights': highlights, + 'rank': rank, + }; + } + + SearchHit.fromJson(JSON json) + : score = json['score'], + highlights = json['highlights'], + rank = json['rank']; +} diff --git a/lib/features/documents/repository/document_repository.dart b/lib/features/documents/repository/document_repository.dart new file mode 100644 index 00000000..1a72abba --- /dev/null +++ b/lib/features/documents/repository/document_repository.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart'; + +abstract class DocumentRepository { + Future create( + Uint8List documentBytes, + String filename, { + required String title, + int? documentType, + int? correspondent, + List? tags, + DateTime? createdAt, + }); + Future update(DocumentModel doc); + Future findNextAsn(); + Future find(DocumentFilter filter); + Future> findSimilar(int docId); + Future delete(DocumentModel doc); + Future getMetaData(DocumentModel document); + Future> bulkDelete(List models); + Future getPreview(int docId); + String getThumbnailUrl(int docId); + Future waitForConsumptionFinished(String filename, String title); + Future download(DocumentModel document); + + Future> autocomplete(String query, [int limit = 10]); +} diff --git a/lib/features/documents/repository/document_repository_impl.dart b/lib/features/documents/repository/document_repository_impl.dart new file mode 100644 index 00000000..398ea7ed --- /dev/null +++ b/lib/features/documents/repository/document_repository_impl.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/store/local_vault.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/core/util.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/bulk_edit.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:http/http.dart'; +import 'package:http/src/boundary_characters.dart'; //TODO: remove once there is either a paperless API update or there is a better solution... +import 'package:injectable/injectable.dart'; + +@Injectable(as: DocumentRepository) +class DocumentRepositoryImpl implements DocumentRepository { + //// + //final StatusService statusService; + final LocalVault localStorage; + final BaseClient httpClient; + + DocumentRepositoryImpl( + //this.statusService, + this.localStorage, + @Named("timeoutClient") this.httpClient, + ); + @override + Future create( + Uint8List documentBytes, + String filename, { + required String title, + int? documentType, + int? correspondent, + List? tags, + DateTime? createdAt, + }) async { + final auth = await localStorage.loadAuthenticationInformation(); + + if (auth == null) { + throw const ErrorMessage(ErrorCode.notAuthenticated); + } + + // The multipart request has to be generated from scratch as the http library does + // not allow the same key (tags) to be added multiple times. However, this is what the + // paperless api expects, i.e. one block for each tag. + final request = await getIt().postUrl( + Uri.parse("${auth.serverUrl}/api/documents/post_document/"), + ); + + final boundary = _boundaryString(); + + StringBuffer bodyBuffer = StringBuffer(); + + var fields = {}; + + fields.tryPutIfAbsent('title', () => title); + fields.tryPutIfAbsent('created', () => formatDateNullable(createdAt)); + fields.tryPutIfAbsent( + 'correspondent', () => correspondent == null ? null : json.encode(correspondent)); + fields.tryPutIfAbsent( + 'document_type', () => documentType == null ? null : json.encode(documentType)); + + for (final key in fields.keys) { + bodyBuffer.write(_buildMultipartField(key, fields[key]!, boundary)); + } + + for (final tag in tags ?? []) { + bodyBuffer.write(_buildMultipartField('tags', tag.toString(), boundary)); + } + + bodyBuffer.write("--$boundary" + '\r\nContent-Disposition: form-data; name="document"; filename="$filename"' + "\r\nContent-type: application/octet-stream" + "\r\n\r\n"); + + final closing = "\r\n--" + boundary + "--\r\n"; + + // Set headers + request.headers.set(HttpHeaders.contentTypeHeader, "multipart/form-data; boundary=" + boundary); + request.headers.set(HttpHeaders.contentLengthHeader, + "${bodyBuffer.length + closing.length + documentBytes.lengthInBytes}"); + request.headers.set(HttpHeaders.authorizationHeader, "Token ${auth.token}"); + + //Write fields to request + request.write(bodyBuffer.toString()); + //Stream file + await request.addStream(Stream.fromIterable(documentBytes.map((e) => [e]))); + // Write closing boundary to request + request.write(closing); + + final response = await request.close(); + + if (response.statusCode != 200) { + throw ErrorMessage(ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode); + } + } + + String _buildMultipartField(String fieldName, String value, String boundary) { + return '--$boundary' + '\r\nContent-Disposition: form-data; name="$fieldName"' + '\r\nContent-type: text/plain' + '\r\n\r\n' + + value + + '\r\n'; + } + + String _boundaryString() { + Random _random = Random(); + var prefix = 'dart-http-boundary-'; + var list = List.generate(70 - prefix.length, + (index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)], + growable: false); + return '$prefix${String.fromCharCodes(list)}'; + } + + @override + Future update(DocumentModel doc) async { + final response = await httpClient.put(Uri.parse("/api/documents/${doc.id}/"), + body: json.encode(doc.toJson()), + headers: {"Content-Type": "application/json"}).timeout(requestTimeout); + if (response.statusCode == 200) { + return DocumentModel.fromJson(jsonDecode(response.body)); + } else { + throw const ErrorMessage(ErrorCode.documentUpdateFailed); + } + } + + @override + Future> find(DocumentFilter filter) async { + final filterParams = filter.toQueryString(); + final response = await httpClient.get( + Uri.parse("/api/documents/?$filterParams"), + ); + if (response.statusCode == 200) { + final searchResult = PagedSearchResult.fromJson( + jsonDecode(const Utf8Decoder().convert(response.body.codeUnits)), + DocumentModel.fromJson, + ); + return searchResult; + } else { + throw const ErrorMessage(ErrorCode.documentLoadFailed); + } + } + + @override + Future delete(DocumentModel doc) async { + final response = await httpClient.delete(Uri.parse("/api/documents/${doc.id}/")); + + if (response.statusCode == 204) { + return Future.value(doc.id); + } + throw const ErrorMessage(ErrorCode.documentDeleteFailed); + } + + @override + String getThumbnailUrl(int documentId) { + return "/api/documents/$documentId/thumb/"; + } + + String getPreviewUrl(int documentId) { + return "/api/documents/$documentId/preview/"; + } + + @override + Future getPreview(int documentId) async { + final response = await httpClient.get(Uri.parse(getPreviewUrl(documentId))); + if (response.statusCode == 200) { + return response.bodyBytes; + } + throw const ErrorMessage(ErrorCode.documentPreviewFailed); + } + + @override + Future findNextAsn() async { + const DocumentFilter asnQueryFilter = DocumentFilter( + sortField: SortField.archiveSerialNumber, + sortOrder: SortOrder.descending, + page: 1, + pageSize: 1, + ); + try { + final result = await find(asnQueryFilter); + return result.results + .map((e) => e.archiveSerialNumber) + .firstWhere((asn) => asn != null, orElse: () => 0)! + + 1; + } on ErrorMessage catch (_) { + throw const ErrorMessage(ErrorCode.documentAsnQueryFailed); + } + } + + @override + Future> bulkDelete(List documentModels) async { + final List ids = documentModels.map((e) => e.id).toList(); + final action = BulkEditAction.delete(ids); + final response = await httpClient.post( + Uri.parse("/api/documents/bulk_edit/"), + body: json.encode(action.toJson()), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode == 200) { + return ids; + } else { + throw const ErrorMessage(ErrorCode.documentBulkDeleteFailed); + } + } + + @override + Future waitForConsumptionFinished(String fileName, String title) async { + // Always wait 5 seconds, processing usually takes longer... + //await Future.delayed(const Duration(seconds: 5)); + PagedSearchResult results = await find(DocumentFilter.latestDocument); + + while ((results.results.isEmpty || + (results.results[0].originalFileName != fileName && results.results[0].title != title))) { + //TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication... + await Future.delayed(const Duration(seconds: 2)); + results = await find(DocumentFilter.latestDocument); + } + try { + return results.results.first; + } on StateError { + throw const ErrorMessage(ErrorCode.documentUploadFailed); + } + } + + @override + Future download(DocumentModel document) async { + //TODO: Check if this works... + final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/download/")); + return response.bodyBytes; + } + + @override + Future getMetaData(DocumentModel document) async { + final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/metadata/")); + return DocumentMetaData.fromJson(jsonDecode(response.body)); + } + + @override + Future> autocomplete(String query, [int limit = 10]) async { + final response = + await httpClient.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}")); + if (response.statusCode == 200) { + return json.decode(response.body) as List; + } + throw const ErrorMessage(ErrorCode.autocompleteQueryError); + } + + @override + Future> findSimilar(int docId) async { + final response = + await httpClient.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10")); + if (response.statusCode == 200) { + return PagedSearchResult.fromJson( + json.decode(response.body), + SimilarDocumentModel.fromJson, + ).results; + } + throw const ErrorMessage(ErrorCode.similarQueryError); + } +} diff --git a/lib/features/documents/repository/saved_views_repository.dart b/lib/features/documents/repository/saved_views_repository.dart new file mode 100644 index 00000000..4de40b36 --- /dev/null +++ b/lib/features/documents/repository/saved_views_repository.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/util.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:http/http.dart'; +import 'package:injectable/injectable.dart'; + +abstract class SavedViewsRepository { + Future> getAll(); + + Future save(SavedView view); + Future delete(SavedView view); +} + +@Injectable(as: SavedViewsRepository) +class SavedViewRepositoryImpl implements SavedViewsRepository { + final BaseClient httpClient; + + SavedViewRepositoryImpl(@Named("timeoutClient") this.httpClient); + + @override + Future> getAll() { + return getCollection( + "/api/saved_views/", + SavedView.fromJson, + ErrorCode.loadSavedViewsError, + ); + } + + @override + Future save(SavedView view) async { + final response = await httpClient.post( + Uri.parse("/api/saved_views/"), + body: jsonEncode(view.toJson()), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode == 201) { + return SavedView.fromJson(jsonDecode(response.body)); + } + throw ErrorMessage(ErrorCode.createSavedViewError, httpStatusCode: response.statusCode); + } + + @override + Future delete(SavedView view) async { + final response = await httpClient.delete(Uri.parse("/api/saved_views/${view.id}/")); + if (response.statusCode == 204) { + return view.id!; + } + throw ErrorMessage(ErrorCode.deleteSavedViewError, httpStatusCode: response.statusCode); + } +} diff --git a/lib/features/documents/view/pages/document_details_page.dart b/lib/features/documents/view/pages/document_details_page.dart new file mode 100644 index 00000000..424b2894 --- /dev/null +++ b/lib/features/documents/view/pages/document_details_page.dart @@ -0,0 +1,418 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/widgets/highlighted_text.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/pages/document_edit_page.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/pages/document_view.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; + +class DocumentDetailsPage extends StatefulWidget { + final int documentId; + const DocumentDetailsPage({ + Key? key, + required this.documentId, + }) : super(key: key); + + @override + State createState() => _DocumentDetailsPageState(); +} + +class _DocumentDetailsPageState extends State { + static final DateFormat _detailedDateFormat = DateFormat("MMM d, yyyy HH:mm:ss"); + + bool _isDownloadPending = false; + bool _isAssignAsnPending = false; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + // buildWhen required because rebuild would happen after delete causing error. + buildWhen: (previous, current) { + return current.documents.where((element) => element.id == widget.documentId).isNotEmpty; + }, + builder: (context, state) { + final document = state.documents.where((doc) => doc.id == widget.documentId).first; + return SafeArea( + bottom: true, + child: DefaultTabController( + length: 3, + child: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.edit), + onPressed: () => _onEdit(document), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(document), + ).padded(const EdgeInsets.symmetric(horizontal: 8.0)), + IconButton( + icon: const Icon(Icons.download), + onPressed: null, //() => _onDownload(document), //TODO: FIX + ), + IconButton( + icon: const Icon(Icons.open_in_new), + onPressed: () => _onOpen(document), + ).padded(const EdgeInsets.symmetric(horizontal: 8.0)), + ], + ), + ), + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar( + leading: IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors + .black, //TODO: check if there is a way to dynamically determine color... + ), + onPressed: () => Navigator.pop(context), + ), + floating: true, + pinned: true, + expandedHeight: 200.0, + flexibleSpace: DocumentPreview( + id: document.id, + fit: BoxFit.cover, + ), + bottom: ColoredTabBar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + tabBar: TabBar( + tabs: [ + Tab( + child: Text( + S.of(context).documentDetailsPageTabOverviewLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + Tab( + child: Text( + S.of(context).documentDetailsPageTabContentLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + Tab( + child: Text( + S.of(context).documentDetailsPageTabMetaDataLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + ], + ), + ), + ), + ], + body: TabBarView( + children: [ + _buildDocumentOverview(document, state.filter.titleAndContentMatchString), + _buildDocumentContentView(document, state.filter.titleAndContentMatchString), + _buildDocumentMetaDataView(document), + ].padded(), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildDocumentMetaDataView(DocumentModel document) { + return FutureBuilder( + future: getIt().getMetaData(document), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final meta = snapshot.data!; + return ListView( + children: [ + _DetailsItem.text(_detailedDateFormat.format(document.modified), + label: S.of(context).documentModifiedPropertyLabel, context: context), + _separator(), + _DetailsItem.text(_detailedDateFormat.format(document.added), + label: S.of(context).documentAddedPropertyLabel, context: context), + _separator(), + _DetailsItem( + label: S.of(context).documentArchiveSerialNumberPropertyLongLabel, + content: document.archiveSerialNumber != null + ? Text(document.archiveSerialNumber.toString()) + : OutlinedButton( + child: Text(S.of(context).documentDetailsPageAssignAsnButtonLabel), + onPressed: () => BlocProvider.of(context).assignAsn(document), + ), + ), + _separator(), + _DetailsItem.text( + meta.mediaFilename, + context: context, + label: S.of(context).documentMetaDataMediaFilenamePropertyLabel, + ), + _separator(), + _DetailsItem.text( + meta.originalChecksum, + context: context, + label: S.of(context).documentMetaDataChecksumLabel, + ), + _separator(), + _DetailsItem.text(formatBytes(meta.originalSize, 2), + label: S.of(context).documentMetaDataOriginalFileSizeLabel, context: context), + _separator(), + _DetailsItem.text( + meta.originalMimeType, + label: S.of(context).documentMetaDataOriginalMimeTypeLabel, + context: context, + ), + _separator(), + ], + ); + }, + ); + } + + Widget _buildDocumentContentView(DocumentModel document, String? match) { + return SingleChildScrollView( + child: _DetailsItem( + content: HighlightedText( + text: document.content ?? "", + highlights: match == null ? [] : match.split(" "), + style: Theme.of(context).textTheme.bodyText2, + caseSensitive: false, + ), + label: S.of(context).documentDetailsPageTabContentLabel, + ), + ); + } + + Widget _buildDocumentOverview(DocumentModel document, String? match) { + return ListView( + children: [ + _DetailsItem( + content: HighlightedText( + text: document.title, + highlights: match?.split(" ") ?? [], + ), + label: S.of(context).documentTitlePropertyLabel, + ), + _separator(), + _DetailsItem.text( + DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()) + .format(document.created), + context: context, + label: S.of(context).documentCreatedPropertyLabel, + ), + _separator(), + _DetailsItem( + content: DocumentTypeWidget( + documentTypeId: document.documentType, + afterSelected: () { + Navigator.pop(context); + }, + ), + label: S.of(context).documentDocumentTypePropertyLabel, + ), + _separator(), + _DetailsItem( + label: S.of(context).documentCorrespondentPropertyLabel, + content: CorrespondentWidget( + correspondentId: document.correspondent, + afterSelected: () { + Navigator.pop(context); + }, + ), + ), + _separator(), + _DetailsItem( + label: S.of(context).documentStoragePathPropertyLabel, + content: StoragePathWidget( + pathId: document.storagePath, + afterSelected: () { + Navigator.pop(context); + }, + ), + ), + _separator(), + _DetailsItem( + label: S.of(context).documentTagsPropertyLabel, + content: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TagsWidget( + tagIds: document.tags, + ), + ), + ), + // _separator(), + // FutureBuilder>( + // future: getIt().findSimilar(document.id), + // builder: (context, snapshot) { + // if (!snapshot.hasData) { + // return CircularProgressIndicator(); + // } + // return ExpansionTile( + // tilePadding: const EdgeInsets.symmetric(horizontal: 8.0), + // title: Text( + // S.of(context).documentDetailsPageSimilarDocumentsLabel, + // style: + // Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold), + // ), + // children: snapshot.data! + // .map((e) => DocumentListItem( + // document: e, + // onTap: (doc) {}, + // isSelected: false, + // isAtLeastOneSelected: false)) + // .toList(), + // ); + // }), + ], + ); + } + + Widget _separator() { + return const SizedBox(height: 32.0); + } + + void _onEdit(DocumentModel document) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => LabelBlocProvider( + child: DocumentEditPage(document: document), + ), + maintainState: true, + ), + ); + } + + Future _onDownload(DocumentModel document) async { + setState(() { + _isDownloadPending = true; + }); + getIt().download(document).then((bytes) async { + //FIXME: logic currently flawed, some error somewhere but cannot look into directory... + final dir = await getApplicationDocumentsDirectory(); + final dirPath = dir.path + "/files/"; + var filePath = dirPath + document.originalFileName; + + if (File(filePath).existsSync()) { + final count = dir + .listSync() + .where((entity) => (entity.path.contains(document.originalFileName))) + .fold(0, (previous, element) => previous + 1); + final extSeperationIdx = filePath.lastIndexOf("."); + filePath = + filePath.replaceRange(extSeperationIdx, extSeperationIdx + 1, " (${count + 1})."); + } + Directory(dirPath).createSync(); + await File(filePath).writeAsBytes(bytes); + _isDownloadPending = false; + showSnackBar(context, "Document successfully downloaded to $filePath"); //TODO: INTL + }); + } + + Future _onDelete(DocumentModel document) async { + showDialog( + context: context, + builder: (context) => DeleteDocumentConfirmationDialog(document: document)).then((delete) { + if (delete ?? false) { + BlocProvider.of(context).removeDocument(document).then((value) { + Navigator.pop(context); + showSnackBar(context, S.of(context).documentDeleteSuccessMessage); + }).onError((error, _) { + showSnackBar(context, translateError(context, error.code)); + }); + } + }); + } + + Future _onOpen(DocumentModel document) async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DocumentView(document: document), + ), + ); + } + + static String formatBytes(int bytes, int decimals) { + if (bytes <= 0) return "0 B"; + const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + var i = (log(bytes) / log(1024)).floor(); + return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + ' ' + suffixes[i]; + } +} + +class _DetailsItem extends StatelessWidget { + final String label; + final Widget content; + const _DetailsItem({Key? key, required this.label, required this.content}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold), + ), + content, + ], + ), + ); + } + + _DetailsItem.text( + String text, { + required this.label, + required BuildContext context, + }) : content = Text(text, style: Theme.of(context).textTheme.bodyText2); +} + +class ColoredTabBar extends Container implements PreferredSizeWidget { + ColoredTabBar({ + super.key, + required this.backgroundColor, + required this.tabBar, + }); + + final TabBar tabBar; + final Color backgroundColor; + @override + Size get preferredSize => tabBar.preferredSize; + + @override + Widget build(BuildContext context) => Container( + color: backgroundColor, + child: tabBar, + ); +} diff --git a/lib/features/documents/view/pages/document_edit_page.dart b/lib/features/documents/view/pages/document_edit_page.dart new file mode 100644 index 00000000..c07f6d5c --- /dev/null +++ b/lib/features/documents/view/pages/document_edit_page.dart @@ -0,0 +1,204 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/add_correspondent_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/add_document_type_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/add_storage_path_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:image/image.dart'; +import 'package:intl/intl.dart'; + +class DocumentEditPage extends StatefulWidget { + final DocumentModel document; + const DocumentEditPage({Key? key, required this.document}) : super(key: key); + + @override + State createState() => _DocumentEditPageState(); +} + +class _DocumentEditPageState extends State { + static const fkTitle = "title"; + static const fkCorrespondent = "correspondent"; + static const fkTags = "tags"; + static const fkDocumentType = "documentType"; + static const fkCreatedDate = "createdAtDate"; + static const fkStoragePath = 'storagePath'; + + late Future documentBytes; + + final GlobalKey _formKey = GlobalKey(); + bool _isSubmitLoading = false; + + @override + void initState() { + documentBytes = getIt().getPreview(widget.document.id); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final values = _formKey.currentState!.value; + final updatedDocument = widget.document.copyWith( + title: values[fkTitle], + created: values[fkCreatedDate], + documentType: values[fkDocumentType] as IdQueryParameter, + correspondent: values[fkCorrespondent] as IdQueryParameter, + storagePath: values[fkStoragePath] as IdQueryParameter, + tags: values[fkTags] as IdsQueryParameter, + ); + setState(() { + _isSubmitLoading = true; + }); + await getIt().updateDocument(updatedDocument); + Navigator.pop(context); + showSnackBar(context, "Document successfully updated."); //TODO: INTL + } + }, + icon: const Icon(Icons.save), + label: Text(S.of(context).genericActionSaveLabel), + ), + appBar: AppBar( + title: Text(S.of(context).documentEditPageTitle), + bottom: _isSubmitLoading + ? const PreferredSize( + preferredSize: Size.fromHeight(4), + child: LinearProgressIndicator(), + ) + : null, + ), + extendBody: true, + body: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + top: 8, + left: 8, + right: 8, + ), + child: FormBuilder( + key: _formKey, + child: ListView(children: [ + _buildTitleFormField().padded(), + _buildCreatedAtFormField().padded(), + BlocBuilder>( + builder: (context, state) { + return LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (currentInput) => BlocProvider.value( + value: BlocProvider.of(context), + child: AddDocumentTypePage( + initialName: currentInput, + ), + ), + label: S.of(context).documentDocumentTypePropertyLabel, + initialValue: DocumentTypeQuery.fromId(widget.document.documentType), + state: state, + name: fkDocumentType, + queryParameterIdBuilder: DocumentTypeQuery.fromId, + queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned, + prefixIcon: const Icon(Icons.description_outlined), + ); + }, + ).padded(), + BlocBuilder>( + builder: (context, state) { + return LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => BlocProvider.value( + value: BlocProvider.of(context), + child: AddCorrespondentPage(initalValue: initialValue), + ), + label: S.of(context).documentCorrespondentPropertyLabel, + state: state, + initialValue: CorrespondentQuery.fromId(widget.document.correspondent), + name: fkCorrespondent, + queryParameterIdBuilder: CorrespondentQuery.fromId, + queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned, + prefixIcon: const Icon(Icons.person_outlined), + ); + }, + ).padded(), + BlocBuilder>( + builder: (context, state) { + return LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => BlocProvider.value( + value: BlocProvider.of(context), + child: AddStoragePathPage(initalValue: initialValue), + ), + label: S.of(context).documentStoragePathPropertyLabel, + state: state, + initialValue: StoragePathQuery.fromId(widget.document.storagePath), + name: fkStoragePath, + queryParameterIdBuilder: StoragePathQuery.fromId, + queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned, + prefixIcon: const Icon(Icons.folder_outlined), + ); + }, + ).padded(), + TagFormField( + initialValue: TagsQuery.fromIds(widget.document.tags), + name: fkTags, + ).padded(), + ]), + ), + ), + ); + } + + Widget _buildTitleFormField() { + return FormBuilderTextField( + name: fkTitle, + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + label: Text(S.of(context).documentTitlePropertyLabel), + ), + initialValue: widget.document.title, + ); + } + + Widget _buildCreatedAtFormField() { + return FormBuilderDateTimePicker( + inputType: InputType.date, + name: fkCreatedDate, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_outlined), + label: Text(S.of(context).documentCreatedPropertyLabel), + ), + initialValue: widget.document.created, + format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format + initialEntryMode: DatePickerEntryMode.calendar, + ); + } +} diff --git a/lib/features/documents/view/pages/document_view.dart b/lib/features/documents/view/pages/document_view.dart new file mode 100644 index 00000000..274b0a70 --- /dev/null +++ b/lib/features/documents/view/pages/document_view.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:pdfx/pdfx.dart'; + +class DocumentView extends StatefulWidget { + final DocumentModel document; + + const DocumentView({ + Key? key, + required this.document, + }) : super(key: key); + + @override + State createState() => _DocumentViewState(); +} + +class _DocumentViewState extends State { + late PdfController _pdfController; + + @override + void initState() { + super.initState(); + _pdfController = PdfController( + document: PdfDocument.openData( + getIt().getPreview(widget.document.id), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).documentPreviewPageTitle), + ), + body: PdfView( + builders: PdfViewBuilders( + options: const DefaultBuilderOptions(), + pageLoaderBuilder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ), + controller: _pdfController, + ), + ); + } +} diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart new file mode 100644 index 00000000..6263f031 --- /dev/null +++ b/lib/features/documents/view/pages/documents_page.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/widgets/offline_banner.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/pages/document_details_page.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/grid/document_grid.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/documents_page_app_bar.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; +import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; + +class DocumentsPage extends StatefulWidget { + const DocumentsPage({Key? key}) : super(key: key); + + @override + State createState() => _DocumentsPageState(); +} + +class _DocumentsPageState extends State { + final PagingController _pagingController = + PagingController( + firstPageKey: 1, + ); + + final PanelController _panelController = PanelController(); + ViewType _viewType = ViewType.list; + + @override + void initState() { + super.initState(); + final documentsCubit = BlocProvider.of(context); + if (!documentsCubit.state.isLoaded) { + documentsCubit.loadDocuments().onError( + (error, stackTrace) => showSnackBar( + context, + translateError(context, error.code), + ), + ); + } + _pagingController.addPageRequestListener(_loadNewPage); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + Future _loadNewPage(int pageKey) async { + final documentsCubit = BlocProvider.of(context); + final pageCount = + documentsCubit.state.inferPageCount(pageSize: documentsCubit.state.filter.pageSize); + if (pageCount <= pageKey + 1) { + _pagingController.nextPageKey = null; + } + documentsCubit.loadMore(); + } + + void _onSelected(DocumentModel model) { + BlocProvider.of(context).toggleDocumentSelection(model); + } + + Future _onRefresh() { + final documentsCubit = BlocProvider.of(context); + return documentsCubit + .updateFilter(filter: documentsCubit.state.filter.copyWith(page: 1)) + .onError((error, _) { + showSnackBar(context, translateError(context, error.code)); + }); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + if (_panelController.isPanelOpen) { + FocusScope.of(context).unfocus(); + _panelController.close(); + return false; + } + final docBloc = BlocProvider.of(context); + if (docBloc.state.selection.isNotEmpty) { + docBloc.resetSelection(); + return false; + } + return true; + }, + child: BlocConsumer( + listenWhen: (previous, current) => + previous != ConnectivityState.connected && current == ConnectivityState.connected, + listener: (context, state) { + BlocProvider.of(context).loadDocuments(); + }, + builder: (context, connectivityState) { + return Scaffold( + drawer: BlocProvider.value( + value: BlocProvider.of(context), + child: const InfoDrawer(), + ), + resizeToAvoidBottomInset: true, + appBar: connectivityState == ConnectivityState.connected ? null : const OfflineBanner(), + body: SlidingUpPanel( + backdropEnabled: true, + parallaxEnabled: true, + parallaxOffset: .5, + controller: _panelController, + defaultPanelState: PanelState.CLOSED, + minHeight: 48, + maxHeight: MediaQuery.of(context).size.height - + kBottomNavigationBarHeight - + 2 * kToolbarHeight, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + body: _buildBody(connectivityState), + color: Theme.of(context).scaffoldBackgroundColor, + panelBuilder: (scrollController) => DocumentFilterPanel( + panelController: _panelController, + scrollController: scrollController, + ), + ), + ); + }, + ), + ); + } + + Widget _buildBody(ConnectivityState connectivityState) { + return BlocBuilder( + builder: (context, state) { + // Some ugly tricks to make it work with bloc, update pageController + _pagingController.value = PagingState( + itemList: state.documents, + nextPageKey: state.nextPageNumber, + ); + + late Widget child; + switch (_viewType) { + case ViewType.list: + child = DocumentListView( + onTap: _openDocumentDetails, + state: state, + onSelected: _onSelected, + pagingController: _pagingController, + hasInternetConnection: connectivityState == ConnectivityState.connected, + ); + break; + case ViewType.grid: + child = DocumentGridView( + onTap: _openDocumentDetails, + state: state, + onSelected: _onSelected, + pagingController: _pagingController, + hasInternetConnection: connectivityState == ConnectivityState.connected); + break; + } + + if (state.isLoaded && state.documents.isEmpty) { + child = SliverToBoxAdapter( + child: DocumentsEmptyState( + state: state, + ), + ); + } + + return RefreshIndicator( + onRefresh: _onRefresh, + child: Container( + padding: const EdgeInsets.only( + bottom: 142, + ), // Prevents panel from hiding scrollable content + child: CustomScrollView( + slivers: [ + DocumentsPageAppBar( + actions: [ + const SortDocumentsButton(), + IconButton( + icon: Icon( + _viewType == ViewType.grid ? Icons.list : Icons.grid_view, + ), + onPressed: () => setState(() => _viewType = _viewType.toggle()), + ), + ], + ), + child + ], + ), + ), + ); + }, + ); + } + + void _openDocumentDetails(DocumentModel model) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + ], + child: DocumentDetailsPage( + documentId: model.id, + ), + ), + ), + ); + } +} + +enum ViewType { + grid, + list; + + ViewType toggle() { + return this == grid ? list : grid; + } +} diff --git a/lib/features/documents/view/widgets/delete_document_confirmation_dialog.dart b/lib/features/documents/view/widgets/delete_document_confirmation_dialog.dart new file mode 100644 index 00000000..b1b5c230 --- /dev/null +++ b/lib/features/documents/view/widgets/delete_document_confirmation_dialog.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class DeleteDocumentConfirmationDialog extends StatelessWidget { + final DocumentModel document; + const DeleteDocumentConfirmationDialog({super.key, required this.document}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + S.of(context).documentsPageSelectionBulkDeleteDialogWarningTextOne, + ), + const SizedBox(height: 16), + Text( + document.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + Text(S.of(context).documentsPageSelectionBulkDeleteDialogContinueText), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(S.of(context).genericActionCancelLabel), + ), + TextButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.error), + ), + onPressed: () { + Navigator.pop(context, true); + }, + child: Text(S.of(context).genericActionDeleteLabel), + ), + ], + ); + } +} diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart new file mode 100644 index 00000000..16f2cd45 --- /dev/null +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -0,0 +1,45 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:shimmer/shimmer.dart'; + +class DocumentPreview extends StatelessWidget { + final int id; + final BoxFit fit; + final Alignment alignment; + final double borderRadius; + + const DocumentPreview({ + Key? key, + required this.id, + this.fit = BoxFit.cover, + this.alignment = Alignment.center, + this.borderRadius = 8.0, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return + // Hero( + // tag: "document_$id",child: + ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: CachedNetworkImage( + fit: fit, + alignment: Alignment.topCenter, + cacheKey: "thumb_$id", + imageUrl: getIt().getThumbnailUrl(id), + errorWidget: (ctxt, msg, __) => Text(msg), + placeholder: (context, value) => Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: const SizedBox(height: 100, width: 100), + ), + cacheManager: getIt(), + ), + // ), + ); + } +} diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart new file mode 100644 index 00000000..5bf6c84d --- /dev/null +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/widgets/empty_state.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class DocumentsEmptyState extends StatelessWidget { + final DocumentsState state; + const DocumentsEmptyState({ + Key? key, + required this.state, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: EmptyState( + title: S.of(context).documentsPageEmptyStateOopsText, + subtitle: S.of(context).documentsPageEmptyStateNothingHereText, + bottomChild: state.filter != DocumentFilter.initial + ? ElevatedButton( + onPressed: () async { + await BlocProvider.of(context).updateFilter(); + BlocProvider.of(context).resetSelection(); + }, + child: Text( + S.of(context).documentsFilterPageResetFilterLabel, + ), + ).padded() + : null, + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/grid/document_grid.dart b/lib/features/documents/view/widgets/grid/document_grid.dart new file mode 100644 index 00000000..34580ba9 --- /dev/null +++ b/lib/features/documents/view/widgets/grid/document_grid.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +class DocumentGridView extends StatelessWidget { + final void Function(DocumentModel model) onTap; + final void Function(DocumentModel) onSelected; + final PagingController pagingController; + final DocumentsState state; + final bool hasInternetConnection; + + const DocumentGridView({ + super.key, + required this.onTap, + required this.pagingController, + required this.state, + required this.onSelected, + required this.hasInternetConnection, + }); + @override + Widget build(BuildContext context) { + return PagedSliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ), + pagingController: pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return DocumentGridItem( + document: item, + onTap: onTap, + isSelected: state.selection.contains(item), + onSelected: onSelected, + isAtLeastOneSelected: state.selection.isNotEmpty, + ); + }, + noItemsFoundIndicatorBuilder: (context) => + const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/grid/document_grid_item.dart b/lib/features/documents/view/widgets/grid/document_grid_item.dart new file mode 100644 index 00000000..7824f331 --- /dev/null +++ b/lib/features/documents/view/widgets/grid/document_grid_item.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; +import 'package:intl/intl.dart'; + +class DocumentGridItem extends StatelessWidget { + final DocumentModel document; + final bool isSelected; + final void Function(DocumentModel) onTap; + final void Function(DocumentModel) onSelected; + final bool isAtLeastOneSelected; + + const DocumentGridItem({ + Key? key, + required this.document, + required this.onTap, + required this.onSelected, + required this.isSelected, + required this.isAtLeastOneSelected, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + onLongPress: () => onSelected(document), + child: AbsorbPointer( + absorbing: isAtLeastOneSelected, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + elevation: 1.0, + color: isSelected + ? Theme.of(context).colorScheme.inversePrimary + : Theme.of(context).cardColor, + child: Column( + children: [ + AspectRatio( + aspectRatio: 1, + child: DocumentPreview( + id: document.id, + borderRadius: 12.0, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CorrespondentWidget(correspondentId: document.correspondent), + DocumentTypeWidget(documentTypeId: document.documentType), + Text( + document.title, + maxLines: document.tags.isEmpty ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + TagsWidget( + tagIds: document.tags, + isMultiLine: false, + ), + Text(DateFormat.yMMMd(Intl.getCurrentLocale()).format(document.created)), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _onTap() { + if (isAtLeastOneSelected || isSelected) { + onSelected(document); + } else { + onTap(document); + } + } +} diff --git a/lib/features/documents/view/widgets/list/document_list.dart b/lib/features/documents/view/widgets/list/document_list.dart new file mode 100644 index 00000000..8a114915 --- /dev/null +++ b/lib/features/documents/view/widgets/list/document_list.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.dart'; +import 'package:flutter_paperless_mobile/core/widgets/offline_widget.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +class DocumentListView extends StatelessWidget { + final void Function(DocumentModel model) onTap; + final void Function(DocumentModel) onSelected; + final PagingController pagingController; + final DocumentsState state; + final bool hasInternetConnection; + + const DocumentListView({ + super.key, + required this.onTap, + required this.pagingController, + required this.state, + required this.onSelected, + required this.hasInternetConnection, + }); + @override + Widget build(BuildContext context) { + return PagedSliverList( + pagingController: pagingController, + builderDelegate: PagedChildBuilderDelegate( + animateTransitions: true, + itemBuilder: (context, item, index) { + return DocumentListItem( + document: item, + onTap: onTap, + isSelected: state.selection.contains(item), + onSelected: onSelected, + isAtLeastOneSelected: state.selection.isNotEmpty, + ); + }, + noItemsFoundIndicatorBuilder: (context) => + hasInternetConnection ? const DocumentsListLoadingWidget() : const OfflineWidget(), + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/list/document_list_item.dart b/lib/features/documents/view/widgets/list/document_list_item.dart new file mode 100644 index 00000000..d382c262 --- /dev/null +++ b/lib/features/documents/view/widgets/list/document_list_item.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; + +class DocumentListItem extends StatelessWidget { + static const a4AspectRatio = 1 / 1.4142; + final DocumentModel document; + final bool isSelected; + final void Function(DocumentModel) onTap; + final void Function(DocumentModel)? onSelected; + final bool isAtLeastOneSelected; + + const DocumentListItem({ + Key? key, + required this.document, + required this.onTap, + this.onSelected, + required this.isSelected, + required this.isAtLeastOneSelected, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: ListTile( + dense: true, + selected: isSelected, + onTap: () => _onTap(), + selectedTileColor: Theme.of(context).colorScheme.inversePrimary, + onLongPress: () => onSelected?.call(document), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + children: [ + AbsorbPointer( + absorbing: isAtLeastOneSelected, + child: CorrespondentWidget( + correspondentId: document.correspondent, + afterSelected: () {}, + ), + ), + ], + ), + Text( + document.title, + overflow: TextOverflow.ellipsis, + maxLines: document.tags.isEmpty ? 2 : 1, + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: AbsorbPointer( + absorbing: isAtLeastOneSelected, + child: TagsWidget( + tagIds: document.tags, + isMultiLine: false, + ), + ), + ), + isThreeLine: document.tags.isNotEmpty, + leading: AspectRatio( + aspectRatio: a4AspectRatio, + child: GestureDetector( + child: DocumentPreview( + id: document.id, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + ), + contentPadding: const EdgeInsets.all(8.0), + ), + ); + } + + void _onTap() { + if (isAtLeastOneSelected || isSelected) { + onSelected?.call(document); + } else { + onTap(document); + } + } +} diff --git a/lib/features/documents/view/widgets/order_by_dropdown.dart b/lib/features/documents/view/widgets/order_by_dropdown.dart new file mode 100644 index 00000000..7732f769 --- /dev/null +++ b/lib/features/documents/view/widgets/order_by_dropdown.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; + +class OrderByDropdown extends StatefulWidget { + static const fkOrderBy = "orderBy"; + const OrderByDropdown({super.key}); + + @override + State createState() => _OrderByDropdownState(); +} + +class _OrderByDropdownState extends State { + @override + Widget build(BuildContext context) { + return FormBuilderDropdown( + name: OrderByDropdown.fkOrderBy, + items: const [], + ); + } +} diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart new file mode 100644 index 00000000..46884c7e --- /dev/null +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -0,0 +1,530 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/query_type_form_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:flutter_paperless_mobile/features/scan/view/document_upload_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:intl/intl.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; + +enum DateRangeSelection { before, after } + +class DocumentFilterPanel extends StatefulWidget { + final PanelController panelController; + final ScrollController scrollController; + + const DocumentFilterPanel({ + Key? key, + required this.panelController, + required this.scrollController, + }) : super(key: key); + + @override + State createState() => _DocumentFilterPanelState(); +} + +class _DocumentFilterPanelState extends State { + static const fkCorrespondent = DocumentModel.correspondentKey; + static const fkDocumentType = DocumentModel.documentTypeKey; + static const fkStoragePath = DocumentModel.storagePathKey; + static const fkQuery = "query"; + static const fkCreatedAt = DocumentModel.createdKey; + static const fkAddedAt = DocumentModel.addedKey; + + static const _sortFields = [ + SortField.created, + SortField.added, + SortField.modified, + SortField.title, + SortField.correspondentName, + SortField.documentType, + SortField.archiveSerialNumber + ]; + + final _formKey = GlobalKey(); + bool _isQueryLoading = false; + + DateTimeRange? _dateTimeRangeOfNullable(DateTime? start, DateTime? end) { + if (start == null && end == null) { + return null; + } + if (start != null && end != null) { + return DateTimeRange(start: start, end: end); + } + assert(start != null || end != null); + final singleDate = (start ?? end)!; + return DateTimeRange(start: singleDate, end: singleDate); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + // Set initial values, otherwise they would not automatically update. + _patchFromFilter(state.filter); + }, + builder: (context, state) { + return FormBuilder( + key: _formKey, + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + _buildDragLine(), + Align( + alignment: Alignment.topRight, + child: TextButton.icon( + icon: const Icon(Icons.refresh), + label: Text(S.of(context).documentsFilterPageResetFilterLabel), + onPressed: () => _resetFilter(context), + ), + ), + ], + ), + const SizedBox( + height: 8.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).documentsFilterPageTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + TextButton( + onPressed: _onApplyFilter, + child: Text(S.of(context).documentsFilterPageApplyFilterLabel), + ), + ], + ).padded(), + Expanded( + child: ListView( + controller: widget.scrollController, + children: [ + const SizedBox( + height: 16.0, + ), + Align( + alignment: Alignment.centerLeft, + child: Text(S.of(context).documentsFilterPageSearchLabel), + ).padded(), + _buildQueryFormField(state), + _buildSortByChipsList(context, state), + Align( + alignment: Alignment.centerLeft, + child: Text(S.of(context).documentsFilterPageAdvancedLabel), + ).padded(), + _buildCreatedDateRangePickerFormField(state).padded(), + _buildAddedDateRangePickerFormField(state).padded(), + _buildCorrespondentFormField(state).padded(), + _buildDocumentTypeFormField(state).padded(), + _buildStoragePathFormField(state).padded(), + TagFormField( + name: DocumentModel.tagsKey, + initialValue: state.filter.tags, + ).padded(), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _resetFilter(BuildContext context) async { + FocusScope.of(context).unfocus(); + await BlocProvider.of(context).updateFilter(); + BlocProvider.of(context).resetSelection(); + if (!widget.panelController.isPanelClosed) { + widget.panelController.close(); + } + } + + Widget _buildDocumentTypeFormField(DocumentsState docState) { + return BlocBuilder>( + builder: (context, state) { + return LabelFormField( + formBuilderState: _formKey.currentState, + name: fkDocumentType, + state: state, + label: S.of(context).documentDocumentTypePropertyLabel, + initialValue: docState.filter.documentType, + queryParameterIdBuilder: DocumentTypeQuery.fromId, + queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned, + prefixIcon: const Icon(Icons.description_outlined), + ); + }, + ); + } + + Widget _buildStoragePathFormField(DocumentsState docState) { + return BlocBuilder>( + builder: (context, state) { + return LabelFormField( + formBuilderState: _formKey.currentState, + name: fkStoragePath, + state: state, + label: S.of(context).documentStoragePathPropertyLabel, + initialValue: docState.filter.storagePath, + queryParameterIdBuilder: StoragePathQuery.fromId, + queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned, + prefixIcon: const Icon(Icons.folder_outlined), + ); + }, + ); + } + + Widget _buildQueryFormField(DocumentsState state) { + final queryType = _formKey.currentState?.getRawValue(QueryTypeFormField.fkQueryType) ?? + QueryType.titleAndContent; + late String label; + switch (queryType) { + case QueryType.title: + label = S.of(context).documentsFilterPageQueryOptionsTitleLabel; + break; + case QueryType.titleAndContent: + label = S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel; + break; + case QueryType.extended: + label = S.of(context).documentsFilterPageQueryOptionsExtendedLabel; + break; + } + + return FormBuilderTextField( + name: fkQuery, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_outlined), + labelText: label, + suffixIcon: QueryTypeFormField( + initialValue: state.filter.queryType, + afterSelected: (queryType) => setState(() {}), + ), + ), + initialValue: state.filter.queryText, + ).padded(); + } + + Widget _buildDateRangePickerHelper(DocumentsState state, String formFieldKey) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ActionChip( + label: Text( + S.of(context).documentsFilterPageDateRangeLastSevenDaysLabel, + ), + onPressed: () { + _formKey.currentState?.fields[formFieldKey]?.didChange( + DateTimeRange( + start: DateUtils.addDaysToDate(DateTime.now(), -7), + end: DateTime.now(), + ), + ); + }, + ).padded(const EdgeInsets.only(right: 8.0)), + ActionChip( + label: Text( + S.of(context).documentsFilterPageDateRangeLastMonthLabel, + ), + onPressed: () { + final now = DateTime.now(); + final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -1); + _formKey.currentState?.fields[formFieldKey]?.didChange( + DateTimeRange( + start: DateTime(firstDayOfLastMonth.year, firstDayOfLastMonth.month, now.day), + end: DateTime.now(), + ), + ); + }, + ).padded(const EdgeInsets.only(right: 8.0)), + ActionChip( + label: Text( + S.of(context).documentsFilterPageDateRangeLastThreeMonthsLabel, + ), + onPressed: () { + final now = DateTime.now(); + final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -3); + _formKey.currentState?.fields[formFieldKey]?.didChange( + DateTimeRange( + start: DateTime( + firstDayOfLastMonth.year, + firstDayOfLastMonth.month, + now.day, + ), + end: DateTime.now(), + ), + ); + }, + ).padded(const EdgeInsets.only(right: 8.0)), + ActionChip( + label: Text( + S.of(context).documentsFilterPageDateRangeLastYearLabel, + ), + onPressed: () { + final now = DateTime.now(); + final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -12); + _formKey.currentState?.fields[formFieldKey]?.didChange( + DateTimeRange( + start: DateTime( + firstDayOfLastMonth.year, + firstDayOfLastMonth.month, + now.day, + ), + end: DateTime.now(), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildCorrespondentFormField(DocumentsState docState) { + return BlocBuilder>( + builder: (context, state) { + return LabelFormField( + formBuilderState: _formKey.currentState, + name: fkCorrespondent, + state: state, + label: S.of(context).documentCorrespondentPropertyLabel, + initialValue: docState.filter.correspondent, + queryParameterIdBuilder: CorrespondentQuery.fromId, + queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned, + prefixIcon: const Icon(Icons.person_outline), + ); + }, + ); + } + + Widget _buildCreatedDateRangePickerFormField(DocumentsState state) { + return Column( + children: [ + FormBuilderDateRangePicker( + initialValue: _dateTimeRangeOfNullable( + state.filter.createdDateAfter, + state.filter.createdDateBefore, + ), + pickerBuilder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + primaryColor: Theme.of(context).primaryColor, + colorScheme: Theme.of(context).colorScheme, + buttonTheme: Theme.of(context).buttonTheme, + ), + child: child!, + ); + }, + format: DateFormat.yMMMd(Localizations.localeOf(context).toString()), + fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel, + fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel, + firstDate: DateTime.fromMicrosecondsSinceEpoch(0), + lastDate: DateTime.now(), + name: fkCreatedAt, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_outlined), + labelText: S.of(context).documentCreatedPropertyLabel, + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _formKey.currentState?.fields[fkCreatedAt]?.didChange(null), + ), + ), + ), + _buildDateRangePickerHelper(state, fkCreatedAt), + ], + ); + } + + Widget _buildAddedDateRangePickerFormField(DocumentsState state) { + return Column( + children: [ + FormBuilderDateRangePicker( + initialValue: _dateTimeRangeOfNullable( + state.filter.addedDateAfter, + state.filter.addedDateBefore, + ), + pickerBuilder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + primaryColor: Theme.of(context).primaryColor, + colorScheme: Theme.of(context).colorScheme, + buttonTheme: Theme.of(context).buttonTheme, + ), + child: child!, + ); + }, + format: DateFormat.yMMMd(Localizations.localeOf(context).toString()), + fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel, + fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel, + firstDate: DateTime.fromMicrosecondsSinceEpoch(0), + lastDate: DateTime.now(), + name: fkAddedAt, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_outlined), + labelText: S.of(context).documentAddedPropertyLabel, + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _formKey.currentState?.fields[fkAddedAt]?.didChange(null), + ), + ), + ), + _buildDateRangePickerHelper(state, fkAddedAt), + ], + ); + } + + Widget _buildDragLine() { + return Container( + width: 48, + height: 5, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + ); + } + + Widget _buildSortByChipsList(BuildContext context, DocumentsState state) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context).documentsPageOrderByLabel, + ), + SizedBox( + height: kToolbarHeight, + child: ListView.separated( + itemCount: _sortFields.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const SizedBox( + width: 8.0, + ), + itemBuilder: (context, index) => + _buildActionChip(_sortFields[index], state.filter.sortField, context), + ), + ), + ], + ), + ); + } + + Widget _buildActionChip( + SortField sortField, SortField? currentlySelectedOrder, BuildContext context) { + String text; + switch (sortField) { + case SortField.archiveSerialNumber: + text = S.of(context).documentArchiveSerialNumberPropertyShortLabel; + break; + case SortField.correspondentName: + text = S.of(context).documentCorrespondentPropertyLabel; + break; + case SortField.title: + text = S.of(context).documentTitlePropertyLabel; + break; + case SortField.documentType: + text = S.of(context).documentDocumentTypePropertyLabel; + break; + case SortField.created: + text = S.of(context).documentCreatedPropertyLabel; + break; + case SortField.added: + text = S.of(context).documentAddedPropertyLabel; + break; + case SortField.modified: + text = S.of(context).documentModifiedPropertyLabel; + break; + } + + final docBloc = BlocProvider.of(context); + return ActionChip( + label: Text(text), + avatar: currentlySelectedOrder == sortField + ? const Icon( + Icons.done, + color: Colors.green, + ) + : null, + onPressed: () => + docBloc.updateFilter(filter: docBloc.state.filter.copyWith(sortField: sortField)), + ); + } + + void _onApplyFilter() { + setState(() => _isQueryLoading = true); + _formKey.currentState?.save(); + if (_formKey.currentState?.validate() ?? false) { + final v = _formKey.currentState!.value; + final docCubit = BlocProvider.of(context); + DocumentFilter newFilter = docCubit.state.filter.copyWith( + createdDateBefore: (v[fkCreatedAt] as DateTimeRange?)?.end, + createdDateAfter: (v[fkCreatedAt] as DateTimeRange?)?.start, + correspondent: v[fkCorrespondent] as CorrespondentQuery?, + documentType: v[fkDocumentType] as DocumentTypeQuery?, + storagePath: v[fkStoragePath] as StoragePathQuery?, + tags: v[DocumentModel.tagsKey] as TagsQuery?, + page: 1, + queryText: v[fkQuery] as String?, + addedDateBefore: (v[fkAddedAt] as DateTimeRange?)?.end, + addedDateAfter: (v[fkAddedAt] as DateTimeRange?)?.start, + queryType: v[QueryTypeFormField.fkQueryType] as QueryType, + ); + BlocProvider.of(context).updateFilter(filter: newFilter).then((value) { + BlocProvider.of(context).resetSelection(); + FocusScope.of(context).unfocus(); + widget.panelController.close(); + setState(() => _isQueryLoading = false); + }); + } + } + + void _patchFromFilter(DocumentFilter f) { + _formKey.currentState?.patchValue({ + fkCorrespondent: f.correspondent, + fkDocumentType: f.documentType, + fkQuery: f.queryText, + fkStoragePath: f.storagePath, + DocumentModel.tagsKey: f.tags, + DocumentModel.titleKey: f.queryText, + QueryTypeFormField.fkQueryType: f.queryType, + fkCreatedAt: _dateTimeRangeOfNullable( + f.createdDateAfter, + f.createdDateBefore, + ), + fkAddedAt: _dateTimeRangeOfNullable( + f.addedDateAfter, + f.addedDateBefore, + ), + }); + } +} diff --git a/lib/features/documents/view/widgets/search/query_type_form_field.dart b/lib/features/documents/view/widgets/search/query_type_form_field.dart new file mode 100644 index 00000000..10b2316c --- /dev/null +++ b/lib/features/documents/view/widgets/search/query_type_form_field.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class QueryTypeFormField extends StatelessWidget { + static const fkQueryType = 'queryType'; + final QueryType? initialValue; + final void Function(QueryType)? afterSelected; + const QueryTypeFormField({ + super.key, + this.initialValue, + this.afterSelected, + }); + + @override + Widget build(BuildContext context) { + return FormBuilderField( + builder: (field) => PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + title: Text(S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel), + ), + value: QueryType.titleAndContent, + ), + PopupMenuItem( + child: ListTile( + title: Text(S.of(context).documentsFilterPageQueryOptionsTitleLabel), + ), + value: QueryType.title, + ), + PopupMenuItem( + child: ListTile( + title: Text(S.of(context).documentsFilterPageQueryOptionsExtendedLabel), + ), + value: QueryType.extended, + ), + //TODO: Add support for ASN queries + ], + onSelected: (selection) { + field.didChange(selection); + afterSelected?.call(selection); + }, + child: const Icon(Icons.more_vert), + ), + initialValue: initialValue, + name: QueryTypeFormField.fkQueryType, + ); + } +} diff --git a/lib/features/documents/view/widgets/selection/add_saved_view_page.dart b/lib/features/documents/view/widgets/selection/add_saved_view_page.dart new file mode 100644 index 00000000..30cc3774 --- /dev/null +++ b/lib/features/documents/view/widgets/selection/add_saved_view_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class AddSavedViewPage extends StatefulWidget { + final DocumentFilter currentFilter; + const AddSavedViewPage({super.key, required this.currentFilter}); + + @override + State createState() => _AddSavedViewPageState(); +} + +class _AddSavedViewPageState extends State { + static const fkName = 'name'; + static const fkShowOnDashboard = 'show_on_dashboard'; + static const fkShowInSidebar = 'show_in_sidebar'; + + final GlobalKey _formKey = GlobalKey(); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).savedViewCreateNewLabel), + actions: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Tooltip( + child: const Icon(Icons.info_outline), + message: S.of(context).savedViewCreateTooltipText, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + onPressed: () => _onCreate(context), + label: Text(S.of(context).genericActionCreateLabel), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: FormBuilder( + key: _formKey, + child: ListView( + children: [ + FormBuilderTextField( + name: fkName, + validator: FormBuilderValidators.required(), //TODO: INTL + decoration: InputDecoration( + label: Text(S.of(context).savedViewNameLabel), + ), + ), + FormBuilderCheckbox( + name: fkShowOnDashboard, + initialValue: false, + title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + FormBuilderCheckbox( + name: fkShowInSidebar, + initialValue: false, + title: Text(S.of(context).savedViewShowInSidebarLabel), + ), + ], + ), + ), + ), + ); + } + + void _onCreate(BuildContext context) { + if (_formKey.currentState?.saveAndValidate() ?? false) { + Navigator.pop( + context, + SavedView.fromDocumentFilter( + widget.currentFilter, + name: _formKey.currentState?.value[fkName] as String, + showOnDashboard: _formKey.currentState?.value[fkShowOnDashboard] as bool, + showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool, + ), + ); + } + } +} diff --git a/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart b/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart new file mode 100644 index 00000000..086a7fa0 --- /dev/null +++ b/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class BulkDeleteConfirmationDialog extends StatelessWidget { + static const _bulletPoint = "\u2022"; + final DocumentsState state; + const BulkDeleteConfirmationDialog({Key? key, required this.state}) + : super(key: key); + + @override + Widget build(BuildContext context) { + assert(state.selection.isNotEmpty); + return AlertDialog( + title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + //TODO: use plurals, didn't use because of crash... investigate later. + state.selection.length == 1 + ? S + .of(context) + .documentsPageSelectionBulkDeleteDialogWarningTextOne + : S + .of(context) + .documentsPageSelectionBulkDeleteDialogWarningTextMany, + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 150), + child: ListView( + shrinkWrap: true, + children: state.selection.map(_buildBulletPoint).toList(), + ), + ), + const SizedBox(height: 16), + Text( + S.of(context).documentsPageSelectionBulkDeleteDialogContinueText), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(S.of(context).genericActionCancelLabel), + ), + TextButton( + style: ButtonStyle( + foregroundColor: + MaterialStateProperty.all(Theme.of(context).colorScheme.error), + ), + onPressed: () { + Navigator.pop(context, true); + }, + child: Text(S.of(context).genericActionDeleteLabel), + ), + ], + ); + } + + Widget _buildBulletPoint(DocumentModel doc) { + return Text( + "\t$_bulletPoint ${doc.title}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart b/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart new file mode 100644 index 00000000..3b5a78fa --- /dev/null +++ b/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class ConfirmDeleteSavedViewDialog extends StatelessWidget { + const ConfirmDeleteSavedViewDialog({ + Key? key, + required this.view, + }) : super(key: key); + + final SavedView view; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + "Delete view " + view.name + "?", + softWrap: true, + ), + content: Text("Do you really want to delete this view?"), + actions: [ + TextButton( + child: Text(S.of(context).genericActionCancelLabel), + onPressed: () => Navigator.pop(context, false), + ), + TextButton( + child: Text( + S.of(context).genericActionDeleteLabel, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onPressed: () => Navigator.pop(context, true), + ), + ], + ); + } +} diff --git a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart new file mode 100644 index 00000000..6fe67257 --- /dev/null +++ b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/saved_view_selection_widget.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class DocumentsPageAppBar extends StatefulWidget with PreferredSizeWidget { + final List actions; + + const DocumentsPageAppBar({ + super.key, + this.actions = const [], + }); + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + @override + State createState() => _DocumentsPageAppBarState(); +} + +class _DocumentsPageAppBarState extends State { + static const _flexibleAreaHeight = kToolbarHeight + 48.0; + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, documentsState) { + if (documentsState.selection.isNotEmpty) { + return SliverAppBar( + snap: true, + floating: true, + pinned: true, + expandedHeight: kToolbarHeight, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => BlocProvider.of(context).resetSelection(), + ), + title: + Text('${documentsState.selection.length} ${S.of(context).documentsSelectedText}'), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(context, documentsState), + ), + ], + ); + } else { + return SliverAppBar( + expandedHeight: kToolbarHeight + _flexibleAreaHeight, + pinned: true, + flexibleSpace: const FlexibleSpaceBar( + background: Padding( + padding: EdgeInsets.all(8.0), + child: SavedViewSelectionWidget(height: _flexibleAreaHeight), + ), + ), + title: BlocBuilder( + builder: (context, state) { + return Text( + '${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})', + ); + }, + ), + actions: widget.actions, + ); + } + }, + ); + } + + void _onDelete(BuildContext context, DocumentsState documentsState) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => BulkDeleteConfirmationDialog(state: documentsState), + ); + if (shouldDelete ?? false) { + BlocProvider.of(context) + .bulkRemoveDocuments(documentsState.selection) + .then((_) => showSnackBar(context, S.of(context).documentsPageBulkDeleteSuccessfulText)) + .onError( + (error, _) => showSnackBar(context, translateError(context, error.code))); + } + } + + String _formatDocumentCount(int count) { + return count > 99 ? "99+" : count.toString(); + } +} diff --git a/lib/features/documents/view/widgets/selection/saved_view_selection_widget.dart b/lib/features/documents/view/widgets/selection/saved_view_selection_widget.dart new file mode 100644 index 00000000..2172d948 --- /dev/null +++ b/lib/features/documents/view/widgets/selection/saved_view_selection_widget.dart @@ -0,0 +1,117 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/add_saved_view_page.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class SavedViewSelectionWidget extends StatelessWidget { + const SavedViewSelectionWidget({ + Key? key, + required this.height, + }) : super(key: key); + + final double height; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BlocBuilder( + builder: (context, state) { + if (state.value.isEmpty) { + return Text(S.of(context).savedViewsEmptyStateText); + } + return SizedBox( + height: 48.0, + child: ListView.separated( + itemCount: state.value.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final view = state.value.values.elementAt(index); + return GestureDetector( + onLongPress: () => _onDelete(context, view), + child: FilterChip( + label: Text(state.value.values.toList()[index].name), + selected: view.id == state.selectedSavedViewId, + onSelected: (isSelected) => _onSelected(isSelected, context, view), + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox( + width: 8.0, + ), + ), + ); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).savedViewsLabel, + style: Theme.of(context).textTheme.titleSmall, + ), + TextButton.icon( + icon: const Icon(Icons.add), + onPressed: () => _onCreatePressed(context), + label: Text(S.of(context).savedViewCreateNewLabel), + ), + ], + ), + ], + ); + } + + void _onCreatePressed(BuildContext context) async { + final newView = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddSavedViewPage(currentFilter: getIt().state.filter), + ), + ); + if (newView != null) { + try { + BlocProvider.of(context).add(newView); + } on ErrorMessage catch (error) { + showError(context, error); + } + } + } + + void _onSelected(bool isSelected, BuildContext context, SavedView view) { + if (isSelected) { + BlocProvider.of(context).updateFilter(filter: view.toDocumentFilter()); + BlocProvider.of(context).selectView(view); + } else { + BlocProvider.of(context).updateFilter(); + BlocProvider.of(context).selectView(null); + } + } + + void _onDelete(BuildContext context, SavedView view) async { + { + final delete = await showDialog( + context: context, + builder: (context) => ConfirmDeleteSavedViewDialog(view: view), + ) ?? + false; + if (delete) { + try { + BlocProvider.of(context).remove(view); + } on ErrorMessage catch (error) { + showError(context, error); + } + } + } + } +} diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart new file mode 100644 index 00000000..97c2e73a --- /dev/null +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class SortDocumentsButton extends StatefulWidget { + const SortDocumentsButton({ + Key? key, + }) : super(key: key); + + @override + State createState() => _SortDocumentsButtonState(); +} + +class _SortDocumentsButtonState extends State { + bool _isLoading = false; + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + Widget child; + if (_isLoading) { + child = const FittedBox( + fit: BoxFit.scaleDown, + child: RefreshProgressIndicator( + strokeWidth: 4.0, + backgroundColor: Colors.transparent, + ), + ); + } else { + final bool isAscending = state.filter.sortOrder == SortOrder.ascending; + child = IconButton( + icon: FaIcon( + isAscending ? FontAwesomeIcons.arrowDownAZ : FontAwesomeIcons.arrowUpZA, + ), + onPressed: () async { + setState(() => _isLoading = true); + BlocProvider.of(context) + .updateFilter( + filter: state.filter.copyWith(sortOrder: state.filter.sortOrder.toggle())) + .whenComplete(() => setState(() => _isLoading = false)); + }, + ); + } + return SizedBox( + height: Theme.of(context).iconTheme.size, + width: Theme.of(context).iconTheme.size, + child: child, + ); + }, + ); + } +} diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart new file mode 100644 index 00000000..4a818a52 --- /dev/null +++ b/lib/features/home/view/home_page.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:flutter_paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart'; +import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/labels_page.dart'; +import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; +import 'package:flutter_paperless_mobile/features/scan/view/scanner_page.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + initializeLabelData(context); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + //Only re-initialize data if the connectivity changed from not connected to connected + listenWhen: (previous, current) => + previous != ConnectivityState.connected && current == ConnectivityState.connected, + listener: (context, state) { + initializeLabelData(context); + }, + child: Scaffold( + key: rootScaffoldKey, + bottomNavigationBar: BottomNavBar( + selectedIndex: _currentIndex, + onNavigationChanged: (index) => setState(() => _currentIndex = index), + ), + drawer: const InfoDrawer(), + body: [ + MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + ], + child: const DocumentsPage(), + ), + BlocProvider.value( + value: getIt(), + child: const ScannerPage(), + ), + const LabelsPage(), + ][_currentIndex], + ), + ); + } + + initializeLabelData(BuildContext context) { + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + } +} diff --git a/lib/features/home/view/widget/bottom_navigation_bar.dart b/lib/features/home/view/widget/bottom_navigation_bar.dart new file mode 100644 index 00000000..ed15ec8d --- /dev/null +++ b/lib/features/home/view/widget/bottom_navigation_bar.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class BottomNavBar extends StatelessWidget { + final int selectedIndex; + final void Function(int) onNavigationChanged; + + const BottomNavBar( + {Key? key, + required this.selectedIndex, + required this.onNavigationChanged}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return NavigationBar( + elevation: 4.0, + onDestinationSelected: onNavigationChanged, + selectedIndex: selectedIndex, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.description), + label: S.of(context).bottomNavDocumentsPageLabel, + ), + NavigationDestination( + icon: const Icon(Icons.document_scanner), + label: S.of(context).bottomNavScannerPageLabel, + ), + NavigationDestination( + icon: const Icon(Icons.sell), + label: S.of(context).bottomNavLabelsPageLabel, + ), + ], + ); + } +} diff --git a/lib/features/home/view/widget/info_drawer.dart b/lib/features/home/view/widget/info_drawer.dart new file mode 100644 index 00000000..74ecdd35 --- /dev/null +++ b/lib/features/home/view/widget/info_drawer.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/settings_page.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; + +class InfoDrawer extends StatelessWidget { + const InfoDrawer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Drawer( + child: ListView( + children: [ + DrawerHeader( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Image.asset( + "assets/logos/paperless_logo_white.png", + height: 32, + width: 32, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ).padded(const EdgeInsets.only(right: 8.0)), + Text( + S.of(context).appTitleText, + style: Theme.of(context) + .textTheme + .headline5! + .copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ], + ), + Align( + alignment: Alignment.bottomRight, + child: BlocBuilder( + builder: (context, state) { + return Text( + state.authentication?.serverUrl.replaceAll(RegExp(r'https?://'), "") ?? "", + textAlign: TextAlign.end, + style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer), + ); + }, + ), + ), + ], + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + ), + ListTile( + leading: const Icon(Icons.settings), + title: Text( + S.of(context).appDrawerSettingsLabel, + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: getIt(), + child: const SettingsPage(), + ), + ), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.bug_report), + title: Text(S.of(context).appDrawerReportBugLabel), + onTap: () { + launchUrlString("https://github.com/astubenbord/paperless-mobile/issues/new"); + }, + ), + const Divider(), + AboutListTile( + icon: const Icon(Icons.info), + applicationIcon: const ImageIcon(AssetImage("assets/logos/paperless_logo_green.png")), + applicationName: "Paperless Mobile", + applicationVersion: kPackageInfo.version + "+" + kPackageInfo.buildNumber, + aboutBoxChildren: [ + Text('${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'), + ], + child: Text(S.of(context).appDrawerAboutLabel), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: Text(S.of(context).appDrawerLogoutLabel), + onTap: () { + // Clear all bloc data + BlocProvider.of(context).logout(); + getIt().reset(); + getIt().reset(); + getIt().reset(); + getIt().reset(); + getIt().reset(); + }, + ), + const Divider(), + ], + ), + ); + } +} diff --git a/lib/features/labels/correspondent/bloc/correspondents_cubit.dart b/lib/features/labels/correspondent/bloc/correspondents_cubit.dart new file mode 100644 index 00000000..c4a175d1 --- /dev/null +++ b/lib/features/labels/correspondent/bloc/correspondents_cubit.dart @@ -0,0 +1,22 @@ +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class CorrespondentCubit extends LabelCubit { + CorrespondentCubit(super.metaDataService); + + @override + Future initialize() async { + return labelRepository.getCorrespondents().then(loadFrom); + } + + @override + Future save(Correspondent item) => labelRepository.saveCorrespondent(item); + + @override + Future update(Correspondent item) => labelRepository.updateCorrespondent(item); + + @override + Future delete(Correspondent item) => labelRepository.deleteCorrespondent(item); +} diff --git a/lib/features/labels/correspondent/model/correspondent.model.dart b/lib/features/labels/correspondent/model/correspondent.model.dart new file mode 100644 index 00000000..4e382a9d --- /dev/null +++ b/lib/features/labels/correspondent/model/correspondent.model.dart @@ -0,0 +1,65 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; + +class Correspondent extends Label { + static const lastCorrespondenceKey = 'last_correspondence'; + + late DateTime? lastCorrespondence; + + Correspondent({ + required super.id, + required super.name, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + this.lastCorrespondence, + }); + + Correspondent.fromJson(JSON json) + : lastCorrespondence = + DateTime.tryParse(json[lastCorrespondenceKey] ?? ''), + super.fromJson(json); + + @override + String toString() { + return name; + } + + @override + void addSpecificFieldsToJson(JSON json) { + json.tryPutIfAbsent( + lastCorrespondenceKey, + () => lastCorrespondence?.toIso8601String(), + ); + } + + @override + Correspondent copyWith({ + int? id, + String? name, + String? slug, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + DateTime? lastCorrespondence, + }) { + return Correspondent( + id: id ?? this.id, + name: name ?? this.name, + documentCount: documentCount ?? documentCount, + isInsensitive: isInsensitive ?? isInsensitive, + lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + slug: slug ?? this.slug, + ); + } + + @override + String get queryEndpoint => 'correspondents'; +} diff --git a/lib/features/labels/correspondent/view/pages/add_correspondent_page.dart b/lib/features/labels/correspondent/view/pages/add_correspondent_page.dart new file mode 100644 index 00000000..c992d026 --- /dev/null +++ b/lib/features/labels/correspondent/view/pages/add_correspondent_page.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class AddCorrespondentPage extends StatelessWidget { + final String? initalValue; + const AddCorrespondentPage({Key? key, this.initalValue}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AddLabelPage( + addLabelStr: S.of(context).addCorrespondentPageTitle, + fromJson: Correspondent.fromJson, + cubit: BlocProvider.of(context), + initialName: initalValue, + ); + } +} diff --git a/lib/features/labels/correspondent/view/pages/edit_correspondent_page.dart b/lib/features/labels/correspondent/view/pages/edit_correspondent_page.dart new file mode 100644 index 00000000..4b24e8f6 --- /dev/null +++ b/lib/features/labels/correspondent/view/pages/edit_correspondent_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class EditCorrespondentPage extends StatelessWidget { + final Correspondent correspondent; + const EditCorrespondentPage({super.key, required this.correspondent}); + + @override + Widget build(BuildContext context) { + return EditLabelPage( + label: correspondent, + onSubmit: BlocProvider.of(context).replace, + onDelete: (correspondent) => _onDelete(correspondent, context), + fromJson: Correspondent.fromJson, + ); + } + + Future _onDelete(Correspondent correspondent, BuildContext context) async { + try { + await BlocProvider.of(context).remove(correspondent); + final cubit = BlocProvider.of(context); + if (cubit.state.filter.correspondent.id == correspondent.id) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset()), + ); + } + } on ErrorMessage catch (e) { + showSnackBar(context, translateError(context, e.code)); + } finally { + Navigator.pop(context); + } + } +} diff --git a/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart b/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart new file mode 100644 index 00000000..4c89f885 --- /dev/null +++ b/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; + +class CorrespondentWidget extends StatelessWidget { + final int? correspondentId; + final void Function()? afterSelected; + final Color? textColor; + final bool isClickable; + + const CorrespondentWidget({ + Key? key, + required this.correspondentId, + this.afterSelected, + this.textColor, + this.isClickable = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AbsorbPointer( + absorbing: !isClickable, + child: BlocBuilder>( + builder: (context, state) { + return GestureDetector( + onTap: () => _addCorrespondentToFilter(context), + child: Text( + (state[correspondentId]?.name) ?? "-", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyText2?.copyWith( + color: textColor ?? Theme.of(context).colorScheme.primary, + ), + ), + ); + }, + ), + ); + } + + void _addCorrespondentToFilter(BuildContext context) { + final cubit = getIt(); + if (cubit.state.filter.correspondent.id == correspondentId) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset())); + } else { + cubit.updateFilter( + filter: cubit.state.filter.copyWith( + correspondent: CorrespondentQuery.fromId(correspondentId), + ), + ); + } + afterSelected?.call(); + } +} diff --git a/lib/features/labels/document_type/bloc/document_type_cubit.dart b/lib/features/labels/document_type/bloc/document_type_cubit.dart new file mode 100644 index 00000000..659dd310 --- /dev/null +++ b/lib/features/labels/document_type/bloc/document_type_cubit.dart @@ -0,0 +1,22 @@ +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class DocumentTypeCubit extends LabelCubit { + DocumentTypeCubit(super.metaDataService); + + @override + Future initialize() async { + return labelRepository.getDocumentTypes().then(loadFrom); + } + + @override + Future save(DocumentType item) => labelRepository.saveDocumentType(item); + + @override + Future update(DocumentType item) => labelRepository.updateDocumentType(item); + + @override + Future delete(DocumentType item) => labelRepository.deleteDocumentType(item); +} diff --git a/lib/features/labels/document_type/model/document_type.model.dart b/lib/features/labels/document_type/model/document_type.model.dart new file mode 100644 index 00000000..b3371fef --- /dev/null +++ b/lib/features/labels/document_type/model/document_type.model.dart @@ -0,0 +1,44 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; + +class DocumentType extends Label { + DocumentType({ + required super.id, + required super.name, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + }); + + DocumentType.fromJson(JSON json) : super.fromJson(json); + + @override + void addSpecificFieldsToJson(JSON json) {} + + @override + String get queryEndpoint => 'document_types'; + + @override + DocumentType copyWith({ + int? id, + String? name, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? slug, + }) { + return DocumentType( + id: id ?? this.id, + name: name ?? this.name, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + isInsensitive: isInsensitive ?? this.isInsensitive, + documentCount: documentCount ?? this.documentCount, + slug: slug ?? this.slug, + ); + } +} diff --git a/lib/features/labels/document_type/model/matching_algorithm.dart b/lib/features/labels/document_type/model/matching_algorithm.dart new file mode 100644 index 00000000..ac4b4861 --- /dev/null +++ b/lib/features/labels/document_type/model/matching_algorithm.dart @@ -0,0 +1,22 @@ +enum MatchingAlgorithm { + anyWord(1, "Any: Match one of the following words"), + allWords(2, "All: Match all of the following words"), + exactMatch(3, "Exact: Match the following string"), + regex(4, "Regex: Match the regular expression"), + similarWord(5, "Similar: Match a similar word"), + auto(6, "Auto: Learn automatic assignment"); + + final int value; + final String name; + + const MatchingAlgorithm(this.value, this.name); + + static MatchingAlgorithm fromInt(int? value) { + return MatchingAlgorithm.values + .where((element) => element.value == value) + .firstWhere( + (element) => true, + orElse: () => MatchingAlgorithm.anyWord, + ); + } +} diff --git a/lib/features/labels/document_type/view/pages/add_document_type_page.dart b/lib/features/labels/document_type/view/pages/add_document_type_page.dart new file mode 100644 index 00000000..6e4b220e --- /dev/null +++ b/lib/features/labels/document_type/view/pages/add_document_type_page.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class AddDocumentTypePage extends StatelessWidget { + final String? initialName; + const AddDocumentTypePage({Key? key, this.initialName}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AddLabelPage( + addLabelStr: S.of(context).addDocumentTypePageTitle, + fromJson: DocumentType.fromJson, + cubit: BlocProvider.of(context), + initialName: initialName, + ); + } +} diff --git a/lib/features/labels/document_type/view/pages/edit_document_type_page.dart b/lib/features/labels/document_type/view/pages/edit_document_type_page.dart new file mode 100644 index 00000000..f941fce8 --- /dev/null +++ b/lib/features/labels/document_type/view/pages/edit_document_type_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class EditDocumentTypePage extends StatelessWidget { + final DocumentType documentType; + const EditDocumentTypePage({super.key, required this.documentType}); + + @override + Widget build(BuildContext context) { + return EditLabelPage( + label: documentType, + onSubmit: BlocProvider.of(context).replace, + onDelete: (docType) => _onDelete(docType, context), + fromJson: DocumentType.fromJson, + ); + } + + Future _onDelete(DocumentType docType, BuildContext context) async { + try { + await BlocProvider.of(context).remove(docType); + final cubit = BlocProvider.of(context); + if (cubit.state.filter.documentType.id == docType.id) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()), + ); + } + } on ErrorMessage catch (e) { + showSnackBar(context, translateError(context, e.code)); + } finally { + Navigator.pop(context); + } + } +} diff --git a/lib/features/labels/document_type/view/widgets/document_type_widget.dart b/lib/features/labels/document_type/view/widgets/document_type_widget.dart new file mode 100644 index 00000000..66acb35b --- /dev/null +++ b/lib/features/labels/document_type/view/widgets/document_type_widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; + +class DocumentTypeWidget extends StatelessWidget { + final int? documentTypeId; + final void Function()? afterSelected; + const DocumentTypeWidget({ + Key? key, + required this.documentTypeId, + this.afterSelected, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _addDocumentTypeToFilter, + child: BlocBuilder>( + builder: (context, state) { + return Text( + state[documentTypeId]?.toString() ?? "-", + style: Theme.of(context) + .textTheme + .bodyText2! + .copyWith(color: Theme.of(context).colorScheme.primary), + ); + }, + ), + ); + } + + void _addDocumentTypeToFilter() { + final cubit = getIt(); + if (cubit.state.filter.documentType.id == documentTypeId) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset())); + } else { + cubit.updateFilter( + filter: cubit.state.filter.copyWith( + documentType: DocumentTypeQuery.fromId(documentTypeId), + ), + ); + } + if (afterSelected != null) { + afterSelected?.call(); + } + } +} diff --git a/lib/features/labels/model/label.model.dart b/lib/features/labels/model/label.model.dart new file mode 100644 index 00000000..e30b4e8c --- /dev/null +++ b/lib/features/labels/model/label.model.dart @@ -0,0 +1,82 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; + +abstract class Label with EquatableMixin implements Comparable { + static const idKey = "id"; + static const nameKey = "name"; + static const slugKey = "slug"; + static const matchKey = "match"; + static const matchingAlgorithmKey = "matching_algorithm"; + static const isInsensitiveKey = "is_insensitive"; + static const documentCountKey = "document_count"; + + String get queryEndpoint; + + final int? id; + final String name; + final String? slug; + final String? match; + final MatchingAlgorithm? matchingAlgorithm; + final bool? isInsensitive; + final int? documentCount; + + const Label({ + required this.id, + required this.name, + this.match, + this.matchingAlgorithm, + this.isInsensitive, + this.documentCount, + this.slug, + }); + + Label.fromJson(JSON json) + : id = json[idKey], + name = json[nameKey], + slug = json[slugKey], + match = json[matchKey], + matchingAlgorithm = + MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]), + isInsensitive = json[isInsensitiveKey], + documentCount = json[documentCountKey]; + + JSON toJson() { + JSON json = {}; + json.tryPutIfAbsent(idKey, () => id); + json.tryPutIfAbsent(nameKey, () => name); + json.tryPutIfAbsent(slugKey, () => slug); + json.tryPutIfAbsent(matchKey, () => match); + json.tryPutIfAbsent(matchingAlgorithmKey, () => matchingAlgorithm?.value); + json.tryPutIfAbsent(isInsensitiveKey, () => isInsensitive); + json.tryPutIfAbsent(documentCountKey, () => documentCount); + addSpecificFieldsToJson(json); + return json; + } + + void addSpecificFieldsToJson(JSON json); + + Label copyWith({ + int? id, + String? name, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? slug, + }); + + @override + String toString() { + return name; + } + + @override + int compareTo(dynamic other) { + return toString().toLowerCase().compareTo(other.toString().toLowerCase()); + } + + @override + List get props => [id]; +} diff --git a/lib/features/labels/repository/label.repository.dart b/lib/features/labels/repository/label.repository.dart new file mode 100644 index 00000000..b3276252 --- /dev/null +++ b/lib/features/labels/repository/label.repository.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/util.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/repository/label_repository.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:http/http.dart'; +import 'package:injectable/injectable.dart'; + +@Singleton(as: LabelRepository) +class LabelRepositoryImpl implements LabelRepository { + final BaseClient httpClient; + + LabelRepositoryImpl(@Named("timeoutClient") this.httpClient); + + @override + Future getCorrespondent(int id) async { + return getSingleResult( + "/api/correspondents/$id/", + Correspondent.fromJson, + ErrorCode.correspondentLoadFailed, + ); + } + + @override + Future getTag(int id) async { + return getSingleResult("/api/tags/$id/", Tag.fromJson, ErrorCode.tagLoadFailed); + } + + @override + Future> getTags({List? ids}) async { + final results = await getCollection( + "/api/tags/?page=1&page_size=100000", + Tag.fromJson, + ErrorCode.tagLoadFailed, + minRequiredApiVersion: 2, + ); + return results.where((element) => ids?.contains(element.id) ?? true).toList(); + } + + @override + Future getDocumentType(int id) async { + return getSingleResult( + "/api/document_types/$id/", + DocumentType.fromJson, + ErrorCode.documentTypeLoadFailed, + ); + } + + @override + Future> getCorrespondents() { + return getCollection( + "/api/correspondents/?page=1&page_size=100000", + Correspondent.fromJson, + ErrorCode.correspondentLoadFailed, + ); + } + + @override + Future> getDocumentTypes() { + return getCollection( + "/api/document_types/?page=1&page_size=100000", + DocumentType.fromJson, + ErrorCode.documentTypeLoadFailed, + ); + } + + @override + Future saveCorrespondent(Correspondent correspondent) async { + final response = await httpClient.post( + Uri.parse('/api/correspondents/'), + body: json.encode(correspondent.toJson()), + headers: {"Content-Type": "application/json"}, + ); + if (response.statusCode == 201) { + return Correspondent.fromJson(json.decode(response.body)); + } + throw ErrorMessage(ErrorCode.correspondentCreateFailed, httpStatusCode: response.statusCode); + } + + @override + Future saveDocumentType(DocumentType type) async { + final response = await httpClient.post( + Uri.parse('/api/document_types/'), + body: json.encode(type.toJson()), + headers: {"Content-Type": "application/json"}, + ); + if (response.statusCode == 201) { + return DocumentType.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.documentTypeCreateFailed); + } + + @override + Future saveTag(Tag tag) async { + final body = json.encode(tag.toJson()); + final response = await httpClient.post(Uri.parse('/api/tags/'), body: body, headers: { + "Content-Type": "application/json", + "Accept": "application/json; version=2", + }); + if (response.statusCode == 201) { + return Tag.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.tagCreateFailed); + } + + @override + Future getStatistics() async { + final response = await httpClient.get(Uri.parse('/api/statistics/')); + if (response.statusCode == 200) { + return json.decode(response.body)['documents_total']; + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future deleteCorrespondent(Correspondent correspondent) async { + assert(correspondent.id != null); + final response = await httpClient.delete(Uri.parse('/api/correspondents/${correspondent.id}/')); + if (response.statusCode == 204) { + return correspondent.id!; + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future deleteDocumentType(DocumentType documentType) async { + assert(documentType.id != null); + final response = await httpClient.delete(Uri.parse('/api/document_types/${documentType.id}/')); + if (response.statusCode == 204) { + return documentType.id!; + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future deleteTag(Tag tag) async { + assert(tag.id != null); + final response = await httpClient.delete(Uri.parse('/api/tags/${tag.id}/')); + if (response.statusCode == 204) { + return tag.id!; + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future updateCorrespondent(Correspondent correspondent) async { + assert(correspondent.id != null); + final response = await httpClient.put( + Uri.parse('/api/correspondents/${correspondent.id}/'), + headers: {"Content-Type": "application/json"}, + body: json.encode(correspondent.toJson()), + ); + if (response.statusCode == 200) { + return Correspondent.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future updateDocumentType(DocumentType documentType) async { + assert(documentType.id != null); + final response = await httpClient.put( + Uri.parse('/api/document_types/${documentType.id}/'), + headers: {"Content-Type": "application/json"}, + body: json.encode(documentType.toJson()), + ); + if (response.statusCode == 200) { + return DocumentType.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future updateTag(Tag tag) async { + assert(tag.id != null); + final response = await httpClient.put( + Uri.parse('/api/tags/${tag.id}/'), + headers: { + "Accept": "application/json; version=2", + "Content-Type": "application/json", + }, + body: json.encode(tag.toJson()), + ); + if (response.statusCode == 200) { + return Tag.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future deleteStoragePath(StoragePath path) async { + assert(path.id != null); + final response = await httpClient.delete(Uri.parse('/api/storage_paths/${path.id}/')); + if (response.statusCode == 204) { + return path.id!; + } + throw const ErrorMessage(ErrorCode.unknown); + } + + @override + Future getStoragePath(int id) { + return getSingleResult("/api/storage_paths/?page=1&page_size=100000", StoragePath.fromJson, + ErrorCode.storagePathLoadFailed); + } + + @override + Future> getStoragePaths() { + return getCollection( + "/api/storage_paths/?page=1&page_size=100000", + StoragePath.fromJson, + ErrorCode.storagePathLoadFailed, + ); + } + + @override + Future saveStoragePath(StoragePath path) async { + final response = await httpClient.post( + Uri.parse('/api/storage_paths/'), + body: json.encode(path.toJson()), + headers: {"Content-Type": "application/json"}, + ); + if (response.statusCode == 201) { + return StoragePath.fromJson(json.decode(response.body)); + } + throw ErrorMessage(ErrorCode.storagePathCreateFailed, httpStatusCode: response.statusCode); + } + + @override + Future updateStoragePath(StoragePath path) async { + assert(path.id != null); + final response = await httpClient.put( + Uri.parse('/api/storage_paths/${path.id}/'), + headers: {"Content-Type": "application/json"}, + body: json.encode(path.toJson()), + ); + if (response.statusCode == 200) { + return StoragePath.fromJson(json.decode(response.body)); + } + throw const ErrorMessage(ErrorCode.unknown); + } +} diff --git a/lib/features/labels/repository/label_repository.dart b/lib/features/labels/repository/label_repository.dart new file mode 100644 index 00000000..30346833 --- /dev/null +++ b/lib/features/labels/repository/label_repository.dart @@ -0,0 +1,32 @@ +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; + +abstract class LabelRepository { + Future getCorrespondent(int id); + Future> getCorrespondents(); + Future saveCorrespondent(Correspondent correspondent); + Future updateCorrespondent(Correspondent correspondent); + Future deleteCorrespondent(Correspondent correspondent); + + Future getTag(int id); + Future> getTags({List? ids}); + Future saveTag(Tag tag); + Future updateTag(Tag tag); + Future deleteTag(Tag tag); + + Future getDocumentType(int id); + Future> getDocumentTypes(); + Future saveDocumentType(DocumentType type); + Future updateDocumentType(DocumentType documentType); + Future deleteDocumentType(DocumentType documentType); + + Future getStoragePath(int id); + Future> getStoragePaths(); + Future saveStoragePath(StoragePath path); + Future updateStoragePath(StoragePath path); + Future deleteStoragePath(StoragePath path); + + Future getStatistics(); +} diff --git a/lib/features/labels/storage_path/bloc/storage_path_cubit.dart b/lib/features/labels/storage_path/bloc/storage_path_cubit.dart new file mode 100644 index 00000000..e199663f --- /dev/null +++ b/lib/features/labels/storage_path/bloc/storage_path_cubit.dart @@ -0,0 +1,23 @@ +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class StoragePathCubit extends LabelCubit { + StoragePathCubit(super.metaDataService); + + @override + Future initialize() async { + return labelRepository.getStoragePaths().then(loadFrom); + } + + @override + Future save(StoragePath item) => labelRepository.saveStoragePath(item); + + @override + Future update(StoragePath item) => labelRepository.updateStoragePath(item); + + @override + Future delete(StoragePath item) => labelRepository.deleteStoragePath(item); +} diff --git a/lib/features/labels/storage_path/model/storage_path.model.dart b/lib/features/labels/storage_path/model/storage_path.model.dart new file mode 100644 index 00000000..22ee605c --- /dev/null +++ b/lib/features/labels/storage_path/model/storage_path.model.dart @@ -0,0 +1,64 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; + +class StoragePath extends Label { + static const pathKey = 'path'; + + late String? path; + + StoragePath({ + required super.id, + required super.name, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + required this.path, + }); + + StoragePath.fromJson(JSON json) + : path = json[pathKey], + super.fromJson(json); + + @override + String toString() { + return name; + } + + @override + void addSpecificFieldsToJson(JSON json) { + json.tryPutIfAbsent( + pathKey, + () => path, + ); + } + + @override + StoragePath copyWith({ + int? id, + String? name, + String? slug, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? path, + }) { + return StoragePath( + id: id ?? this.id, + name: name ?? this.name, + documentCount: documentCount ?? documentCount, + isInsensitive: isInsensitive ?? isInsensitive, + path: path ?? this.path, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + slug: slug ?? this.slug, + ); + } + + @override + String get queryEndpoint => 'storage_paths'; +} diff --git a/lib/features/labels/storage_path/view/pages/add_storage_path_page.dart b/lib/features/labels/storage_path/view/pages/add_storage_path_page.dart new file mode 100644 index 00000000..9274980b --- /dev/null +++ b/lib/features/labels/storage_path/view/pages/add_storage_path_page.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class AddStoragePathPage extends StatelessWidget { + final String? initalValue; + const AddStoragePathPage({Key? key, this.initalValue}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AddLabelPage( + addLabelStr: S.of(context).addStoragePathPageTitle, + fromJson: StoragePath.fromJson, + cubit: BlocProvider.of(context), + initialName: initalValue, + additionalFields: const [ + StoragePathAutofillFormBuilderField(name: StoragePath.pathKey), + SizedBox(height: 120.0), + ], + ); + } +} diff --git a/lib/features/labels/storage_path/view/pages/edit_storage_path_page.dart b/lib/features/labels/storage_path/view/pages/edit_storage_path_page.dart new file mode 100644 index 00000000..0424d12d --- /dev/null +++ b/lib/features/labels/storage_path/view/pages/edit_storage_path_page.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class EditStoragePathPage extends StatelessWidget { + final StoragePath storagePath; + const EditStoragePathPage({super.key, required this.storagePath}); + + @override + Widget build(BuildContext context) { + return EditLabelPage( + label: storagePath, + onSubmit: BlocProvider.of(context).replace, + onDelete: (correspondent) => _onDelete(correspondent, context), + fromJson: StoragePath.fromJson, + additionalFields: [ + StoragePathAutofillFormBuilderField( + name: StoragePath.pathKey, + initialValue: storagePath.path, + ), + const SizedBox(height: 120.0), + ], + ); + } + + Future _onDelete(StoragePath path, BuildContext context) async { + try { + await BlocProvider.of(context).remove(path); + final cubit = BlocProvider.of(context); + if (cubit.state.filter.storagePath.id == path.id) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(storagePath: const StoragePathQuery.unset())); + } + } on ErrorMessage catch (e) { + showSnackBar(context, translateError(context, e.code)); + } finally { + Navigator.pop(context); + } + } +} diff --git a/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart b/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart new file mode 100644 index 00000000..ec3e8bab --- /dev/null +++ b/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/container.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class StoragePathAutofillFormBuilderField extends StatefulWidget { + final String name; + final String? initialValue; + const StoragePathAutofillFormBuilderField({ + super.key, + required this.name, + this.initialValue, + }); + + @override + State createState() => + _StoragePathAutofillFormBuilderFieldState(); +} + +class _StoragePathAutofillFormBuilderFieldState extends State { + late final TextEditingController _textEditingController; + + late String _exampleOutput; + late bool _showClearIcon; + @override + void initState() { + super.initState(); + _textEditingController = TextEditingController.fromValue( + TextEditingValue(text: widget.initialValue ?? ''), + )..addListener(() { + setState(() { + _showClearIcon = _textEditingController.text.isNotEmpty; + }); + }); + _exampleOutput = _buildExampleOutput(widget.initialValue ?? ''); + _showClearIcon = widget.initialValue?.isNotEmpty ?? false; + } + + @override + Widget build(BuildContext context) { + return FormBuilderField( + name: widget.name, + initialValue: widget.initialValue ?? '', + builder: (field) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _textEditingController, + validator: FormBuilderValidators.required(), //TODO: INTL + decoration: InputDecoration( + label: Text(S.of(context).documentStoragePathPropertyLabel), + suffixIcon: _showClearIcon + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _resetfield(field), + ) + : null), + onChanged: field.didChange, + ), + const SizedBox(height: 8.0), + Text( + "Select to autofill path variable", + style: Theme.of(context).textTheme.caption, + ), + Wrap( + alignment: WrapAlignment.start, + spacing: 8.0, + children: [ + InputChip( + label: Text(S.of(context).documentArchiveSerialNumberPropertyLongLabel), + onPressed: () => _addParameterToInput("{asn}", field), + ), + InputChip( + label: Text(S.of(context).documentCorrespondentPropertyLabel), + onPressed: () => _addParameterToInput("{correspondent}", field), + ), + InputChip( + label: Text(S.of(context).documentDocumentTypePropertyLabel), + onPressed: () => _addParameterToInput("{document_type}", field), + ), + InputChip( + label: Text(S.of(context).documentTagsPropertyLabel), + onPressed: () => _addParameterToInput("{tag_list}", field), + ), + InputChip( + label: Text(S.of(context).documentTitlePropertyLabel), + onPressed: () => _addParameterToInput("{title}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel), + onPressed: () => _addParameterToInput("{created}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterYearLabel})"), + onPressed: () => _addParameterToInput("{created_year}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterMonthLabel})"), + onPressed: () => _addParameterToInput("{created_month}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterDayLabel})"), + onPressed: () => _addParameterToInput("{created_day}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel), + onPressed: () => _addParameterToInput("{added}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterYearLabel})"), + onPressed: () => _addParameterToInput("{added_year}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterMonthLabel})"), + onPressed: () => _addParameterToInput("{added_month}", field), + ), + InputChip( + label: Text(S.of(context).documentCreatedPropertyLabel + + " (${S.of(context).storagePathParameterDayLabel})"), + onPressed: () => _addParameterToInput("{added_day}", field), + ), + ], + ) + ], + ), + ); + } + + void _addParameterToInput(String param, FormFieldState field) { + final text = (field.value ?? "") + param; + field.didChange(text); + _textEditingController.text = text; + } + + String _buildExampleOutput(String input) { + return input + .replaceAll("{asn}", "1234") + .replaceAll("{correspondent}", "My Bank") + .replaceAll("{document_type}", "Invoice") + .replaceAll("{tag_list}", "TODO,University,Work") + .replaceAll("{created}", "2020-02-10") + .replaceAll("{created_year}", "2020") + .replaceAll("{created_month}", "02") + .replaceAll("{created_day}", "10") + .replaceAll("{added}", "2029-12-24") + .replaceAll("{added_year}", "2029") + .replaceAll("{added_month}", "12") + .replaceAll("{added_day}", "24"); + } + + void _resetfield(FormFieldState field) { + field.didChange(""); + _textEditingController.clear(); + } +} diff --git a/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart b/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart new file mode 100644 index 00000000..8ebdec78 --- /dev/null +++ b/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; + +class StoragePathWidget extends StatelessWidget { + final int? pathId; + final void Function()? afterSelected; + final Color? textColor; + final bool isClickable; + + const StoragePathWidget({ + Key? key, + required this.pathId, + this.afterSelected, + this.textColor, + this.isClickable = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AbsorbPointer( + absorbing: !isClickable, + child: BlocBuilder>( + builder: (context, state) { + return GestureDetector( + onTap: () => _addStoragePathToFilter(context), + child: Text( + (state[pathId]?.name) ?? "-", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyText2?.copyWith( + color: textColor ?? Theme.of(context).colorScheme.primary, + ), + ), + ); + }, + ), + ); + } + + void _addStoragePathToFilter(BuildContext context) { + final cubit = getIt(); + if (cubit.state.filter.correspondent.id == pathId) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith(storagePath: const StoragePathQuery.unset())); + } else { + cubit.updateFilter( + filter: cubit.state.filter.copyWith( + storagePath: StoragePathQuery.fromId(pathId), + ), + ); + } + afterSelected?.call(); + } +} diff --git a/lib/features/labels/tags/bloc/tags_cubit.dart b/lib/features/labels/tags/bloc/tags_cubit.dart new file mode 100644 index 00000000..d9588c78 --- /dev/null +++ b/lib/features/labels/tags/bloc/tags_cubit.dart @@ -0,0 +1,22 @@ +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class TagCubit extends LabelCubit { + TagCubit(super.metaDataService); + + @override + Future initialize() async { + return labelRepository.getTags().then(loadFrom); + } + + @override + Future save(Tag item) => labelRepository.saveTag(item); + + @override + Future update(Tag item) => labelRepository.updateTag(item); + + @override + Future delete(Tag item) => labelRepository.deleteTag(item); +} diff --git a/lib/features/labels/tags/model/tag.model.dart b/lib/features/labels/tags/model/tag.model.dart new file mode 100644 index 00000000..44477067 --- /dev/null +++ b/lib/features/labels/tags/model/tag.model.dart @@ -0,0 +1,96 @@ +import 'dart:developer'; +import 'dart:ui'; + +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; + +class Tag extends Label { + static const colorKey = 'color'; + static const isInboxTagKey = 'is_inbox_tag'; + static const textColorKey = 'text_color'; + + final Color? color; + final Color? textColor; + final bool? isInboxTag; + + Tag({ + required super.id, + required super.name, + super.documentCount, + super.isInsensitive, + super.match, + super.matchingAlgorithm, + super.slug, + this.color, + this.textColor, + this.isInboxTag, + }); + + Tag.fromJson(JSON json) + : isInboxTag = json[isInboxTagKey], + textColor = Color(_colorStringToInt(json[textColorKey]) ?? 0), + color = (json[colorKey] is Color) + ? json[colorKey] + : Color(_colorStringToInt(json[colorKey]) ?? 0), + super.fromJson(json); + + @override + String toString() { + return name; + } + + @override + void addSpecificFieldsToJson(JSON json) { + json.tryPutIfAbsent(colorKey, () => _toHex(color)); + json.tryPutIfAbsent(isInboxTagKey, () => isInboxTag); + } + + @override + Tag copyWith({ + int? id, + String? name, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? slug, + Color? color, + Color? textColor, + bool? isInboxTag, + }) { + return Tag( + id: id ?? this.id, + name: name ?? this.name, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + isInsensitive: isInsensitive ?? this.isInsensitive, + documentCount: documentCount ?? this.documentCount, + slug: slug ?? this.slug, + color: color ?? this.color, + textColor: textColor ?? this.textColor, + isInboxTag: isInboxTag ?? this.isInboxTag, + ); + } + + @override + String get queryEndpoint => 'tags'; +} + +/// +/// Taken from [FormBuilderColorPicker]. +/// +String? _toHex(Color? color) { + if (color == null) { + return null; + } + String val = '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}'; + log("Color in Tag#_toHex is $val"); + return val; +} + +int? _colorStringToInt(String? color) { + if (color == null) return null; + return int.tryParse(color.replaceAll("#", "ff"), radix: 16); +} diff --git a/lib/features/labels/tags/view/pages/add_tag_page.dart b/lib/features/labels/tags/view/pages/add_tag_page.dart new file mode 100644 index 00000000..0e800dbb --- /dev/null +++ b/lib/features/labels/tags/view/pages/add_tag_page.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_extra_fields/form_builder_extra_fields.dart'; + +class AddTagPage extends StatelessWidget { + const AddTagPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AddLabelPage( + addLabelStr: S.of(context).addTagPageTitle, + fromJson: Tag.fromJson, + cubit: BlocProvider.of(context), + additionalFields: [ + FormBuilderColorPickerField( + name: Tag.colorKey, + valueTransformer: (color) => "#${color?.value.toRadixString(16)}", + decoration: InputDecoration( + label: Text(S.of(context).tagColorPropertyLabel), + ), + colorPickerType: ColorPickerType.materialPicker, + ), + FormBuilderCheckbox( + name: Tag.isInboxTagKey, + title: Text(S.of(context).tagInboxTagPropertyLabel), + ), + ], + ); + } +} diff --git a/lib/features/labels/tags/view/pages/edit_tag_page.dart b/lib/features/labels/tags/view/pages/edit_tag_page.dart new file mode 100644 index 00000000..3ff592f0 --- /dev/null +++ b/lib/features/labels/tags/view/pages/edit_tag_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_extra_fields/form_builder_extra_fields.dart'; + +class EditTagPage extends StatelessWidget { + final Tag tag; + + const EditTagPage({super.key, required this.tag}); + + @override + Widget build(BuildContext context) { + return EditLabelPage( + label: tag, + onSubmit: BlocProvider.of(context).replace, + onDelete: (tag) => _onDelete(tag, context), + fromJson: Tag.fromJson, + additionalFields: [ + FormBuilderColorPickerField( + initialValue: tag.color, + name: Tag.colorKey, + decoration: InputDecoration( + label: Text(S.of(context).tagColorPropertyLabel), + ), + colorPickerType: ColorPickerType.blockPicker, + ), + FormBuilderCheckbox( + initialValue: tag.isInboxTag, + name: Tag.isInboxTagKey, + title: Text(S.of(context).tagInboxTagPropertyLabel), + ), + ], + ); + } + + Future _onDelete(Tag tag, BuildContext context) async { + try { + await BlocProvider.of(context).remove(tag); + final cubit = BlocProvider.of(context); + final currentFilter = cubit.state.filter; + late DocumentFilter updatedFilter = currentFilter; + if (currentFilter.tags.ids.contains(tag.id)) { + updatedFilter = currentFilter.copyWith( + tags: TagsQuery.fromIds( + currentFilter.tags.ids.where((tagId) => tagId != tag.id).toList())); + } + cubit.updateFilter(filter: updatedFilter); + } on ErrorMessage catch (error) { + showError(context, error); + } + } +} diff --git a/lib/features/labels/tags/view/widgets/form_builder_tag_selection_field.dart b/lib/features/labels/tags/view/widgets/form_builder_tag_selection_field.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lib/features/labels/tags/view/widgets/form_builder_tag_selection_field.dart @@ -0,0 +1 @@ + diff --git a/lib/features/labels/tags/view/widgets/tag_widget.dart b/lib/features/labels/tags/view/widgets/tag_widget.dart new file mode 100644 index 00000000..bb07c875 --- /dev/null +++ b/lib/features/labels/tags/view/widgets/tag_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; + +class TagWidget extends StatelessWidget { + final Tag tag; + final void Function()? afterTagTapped; + const TagWidget({super.key, required this.tag, required this.afterTagTapped}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: BlocBuilder( + builder: (context, state) { + return FilterChip( + selected: state.filter.tags.ids.contains(tag.id), + selectedColor: tag.color, + onSelected: (_) => _addTagToFilter(context), + visualDensity: const VisualDensity(vertical: -2), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + label: Text( + tag.name, + style: TextStyle(color: tag.textColor), + ), + backgroundColor: tag.color, + side: BorderSide.none, + ); + }, + ), + ); + } + + void _addTagToFilter(BuildContext context) { + final cubit = BlocProvider.of(context); + if (cubit.state.filter.tags.ids.contains(tag.id)) { + cubit.updateFilter( + filter: cubit.state.filter.copyWith( + tags: TagsQuery.fromIds(cubit.state.filter.tags.ids.where((id) => id != tag.id).toList()), + ), + ); + } else { + cubit.updateFilter( + filter: cubit.state.filter + .copyWith(tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!])), + ); + } + if (afterTagTapped != null) { + afterTagTapped!(); + } + } +} diff --git a/lib/features/labels/tags/view/widgets/tags_form_field.dart b/lib/features/labels/tags/view/widgets/tags_form_field.dart new file mode 100644 index 00000000..99e61c00 --- /dev/null +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class TagFormField extends StatefulWidget { + final TagsQuery? initialValue; + final String name; + + const TagFormField({ + super.key, + required this.name, + this.initialValue, + }); + + @override + State createState() => _TagFormFieldState(); +} + +class _TagFormFieldState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder>( + builder: (context, tagState) { + return FormBuilderField( + builder: (field) { + final sortedTags = tagState.values.toList() + ..sort( + (a, b) => a.name.compareTo(b.name), + ); + //TODO: this is either not correctly resetting on filter reset or (when adding UniqueKey to FormField or ChipsInput) unmounts widget. + // return ChipsInput( + // chipBuilder: (context, state, data) => Chip( + // onDeleted: () => state.deleteChip(data), + // shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + // backgroundColor: Color(tagState[data]!.color ?? Colors.white.value), + // label: Text( + // tagState[data]!.name, + // style: TextStyle(color: Color(tagState[data]!.textColor ?? Colors.black.value)), + // ), + // ), + // suggestionBuilder: (context, state, data) => ListTile( + // title: Text(tagState[data]!.name), + // textColor: Color(tagState[data]!.textColor!), + // tileColor: Color(tagState[data]!.color!), + // onTap: () => state.selectSuggestion(data), + // ), + // findSuggestions: (query) => tagState.values + // .where((element) => element.name.toLowerCase().startsWith(query.toLowerCase())) + // .map((e) => e.id!) + // .toList(), + // onChanged: (tags) => field.didChange(tags), + // initialValue: field.value!, + // ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context).documentTagsPropertyLabel, + ), + Wrap( + children: sortedTags + .map((tag) => FilterChip( + label: Text( + tag.name, + style: TextStyle( + color: tag.textColor, + ), + ), + selectedColor: tag.color, + selected: field.value?.ids.contains(tag.id) ?? false, + onSelected: (isSelected) { + List ids = [...field.value?.ids ?? []]; + if (isSelected) { + ids.add(tag.id!); + } else { + ids.remove(tag.id); + } + field.didChange(TagsQuery.fromIds(ids)); + }, + backgroundColor: tag.color, + )) + .toList() + .padded(const EdgeInsets.only(right: 4.0)), + ), + ], + ); + }, + initialValue: widget.initialValue ?? const TagsQuery.unset(), + name: widget.name, + ); + }, + ); + } +} diff --git a/lib/features/labels/tags/view/widgets/tags_widget.dart b/lib/features/labels/tags/view/widgets/tags_widget.dart new file mode 100644 index 00000000..eb2a5630 --- /dev/null +++ b/lib/features/labels/tags/view/widgets/tags_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tag_widget.dart'; + +class TagsWidget extends StatefulWidget { + final List tagIds; + final bool isMultiLine; + final void Function()? afterTagTapped; + + const TagsWidget({ + Key? key, + required this.tagIds, + this.afterTagTapped, + this.isMultiLine = true, + }) : super(key: key); + + @override + State createState() => _TagsWidgetState(); +} + +class _TagsWidgetState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder>( + builder: (context, state) { + final children = widget.tagIds + .where((id) => state.containsKey(id)) + .map( + (id) => TagWidget( + tag: state[id]!, + afterTagTapped: widget.afterTagTapped, + ), + ) + .toList(); + if (widget.isMultiLine) { + return Wrap( + runAlignment: WrapAlignment.start, + children: children, + runSpacing: 8, + spacing: 4, + ); + } else { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: children, + ), + ); + } + }, + ); + } +} diff --git a/lib/features/labels/view/pages/add_label_page.dart b/lib/features/labels/view/pages/add_label_page.dart new file mode 100644 index 00000000..ed6b2dde --- /dev/null +++ b/lib/features/labels/view/pages/add_label_page.dart @@ -0,0 +1,114 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class AddLabelPage extends StatefulWidget { + final String? initialName; + final String addLabelStr; + final T Function(Map json) fromJson; + final LabelCubit cubit; + final List additionalFields; + + const AddLabelPage({ + Key? key, + this.initialName, + required this.addLabelStr, + required this.fromJson, + required this.cubit, + this.additionalFields = const [], + }) : super(key: key); + + @override + State createState() => _AddLabelPageState(); +} + +class _AddLabelPageState extends State> { + final _formKey = GlobalKey(); + Map _errors = {}; + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(widget.addLabelStr), + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: Text(S.of(context).genericActionCreateLabel), + onPressed: _onSubmit, + ), + body: FormBuilder( + key: _formKey, + child: ListView( + children: [ + FormBuilderTextField( + autovalidateMode: AutovalidateMode.onUserInteraction, + name: Label.nameKey, + decoration: InputDecoration( + labelText: S.of(context).labelNamePropertyLabel, + errorText: _errors[Label.nameKey], + ), + initialValue: widget.initialName, + validator: FormBuilderValidators.required(), + onChanged: (val) => setState(() => _errors = {}), + ), + FormBuilderTextField( + autovalidateMode: AutovalidateMode.onUserInteraction, + name: Label.matchKey, + decoration: InputDecoration( + labelText: S.of(context).labelMatchPropertyLabel, + ), + onChanged: (val) => setState(() => _errors = {}), + ), + FormBuilderDropdown( + name: Label.matchingAlgorithmKey, + initialValue: MatchingAlgorithm.anyWord.value, + decoration: InputDecoration( + labelText: S.of(context).labelMatchingAlgorithmPropertyLabel, + errorText: _errors[Label.matchingAlgorithmKey], + ), + onChanged: (val) => setState(() => _errors = {}), + items: MatchingAlgorithm.values + .map((algo) => DropdownMenuItem( + child: Text(algo.name), //TODO: INTL + value: algo.value)) + .toList(), + ), + FormBuilderCheckbox( + name: Label.isInsensitiveKey, + initialValue: true, + title: Text(S.of(context).labelIsInsensivitePropertyLabel), + ), + ...widget.additionalFields, + ].padded(), + ), + ), + ); + } + + void _onSubmit() async { + log("IsValid? ${_formKey.currentState?.isValid}"); + if (_formKey.currentState?.saveAndValidate() ?? false) { + try { + final label = await widget.cubit.add(widget.fromJson(_formKey.currentState!.value)); + Navigator.pop(context, label); + } on ErrorMessage catch (e) { + showSnackBar(context, translateError(context, e.code)); + } on Map catch (json) { + setState(() => _errors = json); + } + } + } +} diff --git a/lib/features/labels/view/pages/edit_label_page.dart b/lib/features/labels/view/pages/edit_label_page.dart new file mode 100644 index 00000000..30a3fa85 --- /dev/null +++ b/lib/features/labels/view/pages/edit_label_page.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class EditLabelPage extends StatefulWidget { + final T label; + final Future Function(T) onSubmit; + final Future Function(T) onDelete; + final T Function(JSON) fromJson; + final List additionalFields; + + const EditLabelPage({ + Key? key, + required this.label, + required this.fromJson, + required this.onSubmit, + required this.onDelete, + this.additionalFields = const [], + }) : super(key: key); + + @override + State createState() => _EditLabelPageState(); +} + +class _EditLabelPageState extends State> { + final _formKey = GlobalKey(); + + Map _errors = {}; + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(S.of(context).genericActionEditLabel), + actions: [ + IconButton( + onPressed: () => widget.onDelete(widget.label), + icon: const Icon(Icons.delete), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: Text(S.of(context).genericActionUpdateLabel), + onPressed: _onSubmit, + ), + body: FormBuilder( + key: _formKey, + child: ListView( + children: [ + FormBuilderTextField( + name: Label.nameKey, + decoration: InputDecoration( + labelText: S.of(context).labelNamePropertyLabel, + errorText: _errors[Label.nameKey], + ), + validator: FormBuilderValidators.required(), + initialValue: widget.label.name, + onChanged: (val) => setState(() => _errors = {}), + ), + FormBuilderTextField( + name: Label.matchKey, + decoration: InputDecoration( + labelText: S.of(context).labelMatchPropertyLabel, + errorText: _errors[Label.matchKey], + ), + initialValue: widget.label.match, + onChanged: (val) => setState(() => _errors = {}), + ), + FormBuilderDropdown( + name: Label.matchingAlgorithmKey, + initialValue: + widget.label.matchingAlgorithm?.value ?? MatchingAlgorithm.allWords.value, + decoration: InputDecoration( + labelText: S.of(context).labelMatchingAlgorithmPropertyLabel, + errorText: _errors[Label.matchingAlgorithmKey], + ), + onChanged: (val) => setState(() => _errors = {}), + items: MatchingAlgorithm.values + .map( + (algo) => DropdownMenuItem( + child: Text(algo.name), //TODO: INTL + value: algo.value, + ), + ) + .toList(), + ), + FormBuilderCheckbox( + name: Label.isInsensitiveKey, + initialValue: widget.label.isInsensitive, + title: Text(S.of(context).labelIsInsensivitePropertyLabel), + ), + ...widget.additionalFields, + ].padded(), + ), + ), + ); + } + + void _onSubmit() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + try { + final mergedJson = {...widget.label.toJson(), ..._formKey.currentState!.value}; + await widget.onSubmit(widget.fromJson(mergedJson)); + Navigator.pop(context); + } on ErrorMessage catch (e) { + showSnackBar(context, translateError(context, e.code)); + } on Map catch (errorMessages) { + setState(() => _errors = errorMessages); + } + } + } +} diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart new file mode 100644 index 00000000..7ddade55 --- /dev/null +++ b/lib/features/labels/view/pages/labels_page.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/edit_correspondent_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/add_correspondent_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/add_document_type_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/edit_document_type_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/add_storage_path_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/edit_storage_path_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/pages/add_tag_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/pages/edit_tag_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_item.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class LabelsPage extends StatefulWidget { + const LabelsPage({Key? key}) : super(key: key); + + @override + State createState() => _LabelsPageState(); +} + +class _LabelsPageState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + BlocProvider.of(context).initialize(); + + _tabController = TabController(length: 4, vsync: this) + ..addListener(() => setState(() => _currentIndex = _tabController.index)); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + drawer: const InfoDrawer(), + appBar: AppBar( + title: Text( + [ + S.of(context).labelsPageCorrespondentsTitleText, + S.of(context).labelsPageDocumentTypesTitleText, + S.of(context).labelsPageTagsTitleText, + S.of(context).labelsPageStoragePathTitleText + ][_currentIndex], + ), + actions: [ + IconButton( + onPressed: _onAddPressed, + icon: const Icon(Icons.add), + ) + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: ColoredBox( + color: Theme.of(context).bottomAppBarColor, + child: TabBar( + indicatorColor: Theme.of(context).colorScheme.primary, + controller: _tabController, + tabs: [ + Tab( + icon: Icon( + Icons.person_outline, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.description_outlined, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.label_outline, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.folder_open, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ) + ], + ), + ), + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + LabelTabView( + cubit: BlocProvider.of(context), + filterBuilder: (label) => DocumentFilter( + correspondent: CorrespondentQuery.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + onOpenEditPage: _openEditCorrespondentPage, + ), + LabelTabView( + cubit: BlocProvider.of(context), + filterBuilder: (label) => DocumentFilter( + documentType: DocumentTypeQuery.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + onOpenEditPage: _openEditDocumentTypePage, + ), + LabelTabView( + cubit: BlocProvider.of(context), + filterBuilder: (label) => DocumentFilter( + tags: TagsQuery.fromIds([label.id!]), + pageSize: label.documentCount ?? 0, + ), + onOpenEditPage: _openEditTagPage, + leadingBuilder: (t) => CircleAvatar(backgroundColor: t.color), + ), + LabelTabView( + cubit: BlocProvider.of(context), + onOpenEditPage: _openEditStoragePathPage, + filterBuilder: (label) => DocumentFilter( + storagePath: StoragePathQuery.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + contentBuilder: (path) => Text(path.path ?? ""), + ), + ], + ), + ), + ); + } + + void _openEditCorrespondentPage(Correspondent correspondent) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: BlocProvider.of(context)), + ], + child: EditCorrespondentPage(correspondent: correspondent), + ), + ), + ); + } + + void _openEditDocumentTypePage(DocumentType docType) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: BlocProvider.of(context)), + ], + child: EditDocumentTypePage(documentType: docType), + ), + ), + ); + } + + void _openEditTagPage(Tag tag) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: BlocProvider.of(context)), + ], + child: EditTagPage(tag: tag), + ), + ), + ); + } + + void _openEditStoragePathPage(StoragePath path) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: BlocProvider.of(context)), + ], + child: EditStoragePathPage(storagePath: path), + ), + ), + ); + } + + void _onAddPressed() { + Navigator.push(context, MaterialPageRoute( + builder: (context) { + late final Widget page; + switch (_currentIndex) { + case 0: + page = const AddCorrespondentPage(); + break; + case 1: + page = const AddDocumentTypePage(); + break; + case 2: + page = const AddTagPage(); + break; + case 3: + page = const AddStoragePathPage(); + } + return LabelBlocProvider(child: page); + }, + )); + } +} diff --git a/lib/features/labels/view/widgets/label_form_field.dart b/lib/features/labels/view/widgets/label_form_field.dart new file mode 100644 index 00000000..0b6095be --- /dev/null +++ b/lib/features/labels/view/widgets/label_form_field.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_extra_fields/form_builder_extra_fields.dart'; + +/// +/// Form field allowing to select labels (i.e. correspondent, documentType) +/// [T] is the label (model) type, [R] is the return type. +/// +class LabelFormField extends StatefulWidget { + final Widget prefixIcon; + final Map state; + final FormBuilderState? formBuilderState; + final IdQueryParameter? initialValue; + final String name; + final String label; + final FormFieldValidator? validator; + final Widget Function(String)? labelCreationWidgetBuilder; + final R Function() queryParameterNotAssignedBuilder; + final R Function(int? id) queryParameterIdBuilder; + final bool notAssignedSelectable; + + const LabelFormField({ + Key? key, + required this.name, + required this.state, + this.validator, + this.initialValue, + required this.label, + this.labelCreationWidgetBuilder, + required this.queryParameterNotAssignedBuilder, + required this.queryParameterIdBuilder, + required this.formBuilderState, + required this.prefixIcon, + this.notAssignedSelectable = true, + }) : super(key: key); + + @override + State> createState() => _LabelFormFieldState(); +} + +class _LabelFormFieldState + extends State> { + bool _showCreationSuffixIcon = false; + late bool _showClearSuffixIcon; + + late final TextEditingController _textEditingController; + + @override + void initState() { + super.initState(); + _showClearSuffixIcon = widget.state.containsKey(widget.initialValue?.id); + _textEditingController = + TextEditingController(text: widget.state[widget.initialValue?.id]?.name ?? '') + ..addListener(() { + setState(() { + _showCreationSuffixIcon = widget.state.values + .where((item) => item.name.toLowerCase().startsWith( + _textEditingController.text.toLowerCase(), + )) + .isEmpty; + }); + setState(() => _showClearSuffixIcon = _textEditingController.text.isNotEmpty); + }); + } + + @override + Widget build(BuildContext context) { + return FormBuilderTypeAhead( + initialValue: widget.initialValue ?? widget.queryParameterIdBuilder(null), + name: widget.name, + itemBuilder: (context, suggestion) => ListTile( + title: Text(widget.state[suggestion.id]?.name ?? S.of(context).labelNotAssignedText), + ), + suggestionsCallback: (pattern) { + final List suggestions = widget.state.keys + .where((item) => + widget.state[item]!.name.toLowerCase().startsWith(pattern.toLowerCase()) || + pattern.isEmpty) + .map((id) => widget.queryParameterIdBuilder(id)) + .toList(); + if (widget.notAssignedSelectable) { + suggestions.insert(0, widget.queryParameterNotAssignedBuilder()); + } + return suggestions; + }, + onChanged: (value) { + setState(() => _showClearSuffixIcon = value?.isSet ?? false); + }, + controller: _textEditingController, + decoration: InputDecoration( + prefixIcon: widget.prefixIcon, + label: Text(widget.label), + hintText: _getLocalizedHint(context), + suffixIcon: _buildSuffixIcon(context), + ), + selectionToTextTransformer: (suggestion) { + if (suggestion == widget.queryParameterNotAssignedBuilder()) { + return S.of(context).labelNotAssignedText; + } + return widget.state[suggestion.id]?.name ?? ""; + }, + direction: AxisDirection.up, + onSuggestionSelected: (suggestion) => + widget.formBuilderState?.fields[widget.name]?.didChange(suggestion as R), + ); + } + + Widget? _buildSuffixIcon(BuildContext context) { + if (_showCreationSuffixIcon && widget.labelCreationWidgetBuilder != null) { + return IconButton( + onPressed: () => Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => + widget.labelCreationWidgetBuilder!(_textEditingController.text))) + .then((value) { + if (value != null) { + // If new label has been created, set form field value and text of this form field and unfocus keyboard (we assume user is done). + widget.formBuilderState?.fields[widget.name] + ?.didChange(widget.queryParameterIdBuilder(value.id)); + _textEditingController.text = value.name; + FocusScope.of(context).unfocus(); + } else { + _reset(); + } + }), + icon: const Icon( + Icons.new_label, + ), + ); + } + if (_showClearSuffixIcon) { + return IconButton( + icon: const Icon(Icons.clear), + onPressed: _reset, + ); + } + return null; + } + + void _reset() { + widget.formBuilderState?.fields[widget.name]?.didChange(widget.queryParameterIdBuilder(null)); + _textEditingController.clear(); + } + + String _getLocalizedHint(BuildContext context) { + if (T == Correspondent) { + return S.of(context).correspondentFormFieldSearchHintText; + } else if (T == DocumentType) { + return S.of(context).documentTypeFormFieldSearchHintText; + } else { + return S + .of(context) + .tagFormFieldSearchHintText; //TODO: Update tag form field once there is multi selection support. + } + } +} diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart new file mode 100644 index 00000000..3d4f97a9 --- /dev/null +++ b/lib/features/labels/view/widgets/label_item.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/linked_documents_preview.dart'; + +class LabelItem extends StatelessWidget { + final T label; + final String name; + final Widget content; + final void Function(T) onOpenEditPage; + final DocumentFilter Function(T) filterBuilder; + final Widget? leading; + + const LabelItem({ + super.key, + required this.name, + required this.content, + required this.onOpenEditPage, + required this.filterBuilder, + this.leading, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(name), + subtitle: content, + leading: leading, + onTap: () => onOpenEditPage(label), + trailing: _buildDocumentCountWidget(context), + ); + } + + Widget _buildDocumentCountWidget(BuildContext context) { + return TextButton.icon( + label: const Icon(Icons.link), + icon: Text(_formatDocumentCount(label.documentCount)), + onPressed: (label.documentCount ?? 0) == 0 + ? null + : () { + final filter = filterBuilder(label); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LabelBlocProvider( + child: BlocProvider( + create: (context) => + DocumentsCubit(getIt())..updateFilter(filter: filter), + child: LinkedDocumentsPreview(filter: filter), + ), + ), + ), + ); + }, + ); + } + + String _formatDocumentCount(int? count) { + if ((count ?? 0) > 99) { + return "99+"; + } + return (count ?? 0).toString().padLeft(3); + } +} diff --git a/lib/features/labels/view/widgets/label_list_tile.dart b/lib/features/labels/view/widgets/label_list_tile.dart new file mode 100644 index 00000000..459fb4e0 --- /dev/null +++ b/lib/features/labels/view/widgets/label_list_tile.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/linked_documents_preview.dart'; + +class LabelListTile extends StatelessWidget { + final T label; + final DocumentFilter Function(Label) filterBuilder; + final void Function() onOpenEditPage; + + const LabelListTile( + this.label, { + super.key, + required this.filterBuilder, + required this.onOpenEditPage, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: (label is Tag) + ? CircleAvatar( + backgroundColor: (label as Tag).color, + ) + : null, + title: Text(label.name), + onTap: onOpenEditPage, + trailing: _buildDocumentCountWidget(context), + subtitle: Text( + (label.match?.isEmpty ?? true) ? "-" : label.match!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + Widget _buildDocumentCountWidget(BuildContext context) { + return TextButton.icon( + label: const Icon(Icons.link), + icon: Text(_formatDocumentCount(label.documentCount)), + onPressed: (label.documentCount ?? 0) == 0 + ? null + : () { + final filter = filterBuilder(label); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LabelBlocProvider( + child: BlocProvider( + create: (context) => + DocumentsCubit(getIt())..updateFilter(filter: filter), + child: LinkedDocumentsPreview(filter: filter), + ), + ), + ), + ); + }, + ); + } + + String _formatDocumentCount(int? count) { + if ((count ?? 0) > 99) { + return "99+"; + } + return (count ?? 0).toString().padLeft(3); + } +} diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart new file mode 100644 index 00000000..9f31444c --- /dev/null +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_item.dart'; + +class LabelTabView extends StatelessWidget { + final LabelCubit cubit; + final DocumentFilter Function(Label) filterBuilder; + final void Function(T) onOpenEditPage; + + /// Displayed as the subtitle of the [ListTile] + final Widget Function(T)? contentBuilder; + + /// Displayed as the leading widget of the [ListTile] + final Widget Function(T)? leadingBuilder; + + const LabelTabView({ + super.key, + required this.cubit, + required this.filterBuilder, + this.contentBuilder, + this.leadingBuilder, + required this.onOpenEditPage, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder>, Map>( + bloc: cubit, + builder: (context, state) { + final labels = state.values.toList()..sort(); + return RefreshIndicator( + onRefresh: cubit.initialize, + child: ListView( + children: labels + .map((l) => LabelItem( + name: l.name, + content: contentBuilder?.call(l) ?? Text(l.match ?? '-'), + onOpenEditPage: onOpenEditPage, + filterBuilder: filterBuilder, + leading: leadingBuilder?.call(l), + label: l, + )) + .toList(), + ), + ); + }, + ); + } +} diff --git a/lib/features/labels/view/widgets/linked_documents_preview.dart b/lib/features/labels/view/widgets/linked_documents_preview.dart new file mode 100644 index 00000000..c55a54bd --- /dev/null +++ b/lib/features/labels/view/widgets/linked_documents_preview.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/pages/document_details_page.dart'; +import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +class LinkedDocumentsPreview extends StatefulWidget { + final DocumentFilter filter; + + const LinkedDocumentsPreview({super.key, required this.filter}); + + @override + State createState() => _LinkedDocumentsPreviewState(); +} + +class _LinkedDocumentsPreviewState extends State { + final PagingController _pagingController = PagingController(firstPageKey: 1); + + @override + void initState() { + super.initState(); + _pagingController.nextPageKey = null; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).linkedDocumentsPageTitle), + ), + body: BlocBuilder( + builder: (context, state) { + _pagingController.itemList = state.documents; + return CustomScrollView( + slivers: [ + DocumentListView( + onTap: (doc) { + Navigator.push( + context, + MaterialPageRoute( + builder: (ctxt) => LabelBlocProvider( + child: BlocProvider.value( + value: BlocProvider.of(context), + child: DocumentDetailsPage(documentId: doc.id)), + ), + ), + ); + }, + pagingController: _pagingController, + state: state, + onSelected: BlocProvider.of(context).toggleDocumentSelection, + hasInternetConnection: true, + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/login/bloc/authentication_cubit.dart b/lib/features/login/bloc/authentication_cubit.dart new file mode 100644 index 00000000..4cc93bec --- /dev/null +++ b/lib/features/login/bloc/authentication_cubit.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/store/local_vault.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart'; +import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:flutter_paperless_mobile/features/login/model/user_credentials.model.dart'; +import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:injectable/injectable.dart'; + +const authenticationKey = "authentication"; + +@singleton +class AuthenticationCubit extends Cubit { + final LocalVault localStore; + final AuthenticationService authenticationService; + + AuthenticationCubit(this.localStore, this.authenticationService) + : super(AuthenticationState.initial); + + Future initialize() { + return restoreSessionState(); + } + + Future login({ + required UserCredentials credentials, + required String serverUrl, + ClientCertificate? clientCertificate, + }) async { + assert(credentials.username != null && credentials.password != null); + try { + registerSecurityContext(clientCertificate); + } on TlsException catch (_) { + throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration); + } + emit( + AuthenticationState( + isAuthenticated: false, + wasLoginStored: false, + authentication: AuthenticationInformation( + username: credentials.username!, + password: credentials.password!, + serverUrl: serverUrl, + token: "", + clientCertificate: clientCertificate, + ), + ), + ); + final token = await authenticationService.login( + username: credentials.username!, + password: credentials.password!, + serverUrl: serverUrl, + ); + final auth = AuthenticationInformation( + username: credentials.username!, + password: credentials.password!, + token: token, + serverUrl: serverUrl, + clientCertificate: clientCertificate, + ); + + await localStore.storeAuthenticationInformation(auth); + + emit(AuthenticationState( + isAuthenticated: true, + wasLoginStored: false, + authentication: auth, + )); + } + + Future restoreSessionState() async { + final storedAuth = await localStore.loadAuthenticationInformation(); + final appSettings = + await localStore.loadApplicationSettings() ?? ApplicationSettingsState.defaultSettings; + + if (storedAuth == null || !storedAuth.isValid) { + emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false)); + } else { + if (!appSettings.isLocalAuthenticationEnabled || + await authenticationService.authenticateLocalUser("Authenticate to log back in")) { + registerSecurityContext(storedAuth.clientCertificate); + emit( + AuthenticationState( + isAuthenticated: true, + wasLoginStored: true, + authentication: storedAuth, + ), + ); + } else { + emit(AuthenticationState(isAuthenticated: false, wasLoginStored: true)); + } + } + } + + Future logout() async { + await localStore.clear(); + emit(AuthenticationState.initial); + } +} + +class AuthenticationState { + final bool wasLoginStored; + final bool isAuthenticated; + final AuthenticationInformation? authentication; + + static final AuthenticationState initial = AuthenticationState( + wasLoginStored: false, + isAuthenticated: false, + ); + + AuthenticationState({ + required this.isAuthenticated, + required this.wasLoginStored, + this.authentication, + }); + + AuthenticationState copyWith({ + bool? wasLoginStored, + bool? isAuthenticated, + AuthenticationInformation? authentication, + }) { + return AuthenticationState( + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + wasLoginStored: wasLoginStored ?? this.wasLoginStored, + authentication: authentication ?? this.authentication, + ); + } +} diff --git a/lib/features/login/bloc/local_authentication_cubit.dart b/lib/features/login/bloc/local_authentication_cubit.dart new file mode 100644 index 00000000..d6d5ecbd --- /dev/null +++ b/lib/features/login/bloc/local_authentication_cubit.dart @@ -0,0 +1,24 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:local_auth/local_auth.dart'; + +class LocalAuthenticationCubit extends Cubit { + LocalAuthenticationCubit() : super(LocalAuthenticationState(false)); + + Future authorize(String localizedMessage) async { + final isAuthenticationSuccessful = await getIt() + .authenticate(localizedReason: localizedMessage); + if (isAuthenticationSuccessful) { + emit(LocalAuthenticationState(true)); + } else { + throw const ErrorMessage(ErrorCode.biometricAuthenticationFailed); + } + } +} + +class LocalAuthenticationState { + final bool isAuthorized; + + LocalAuthenticationState(this.isAuthorized); +} diff --git a/lib/features/login/model/authentication_information.dart b/lib/features/login/model/authentication_information.dart new file mode 100644 index 00000000..5bbb4be8 --- /dev/null +++ b/lib/features/login/model/authentication_information.dart @@ -0,0 +1,66 @@ +import 'package:flutter_paperless_mobile/core/type/json.dart'; +import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart'; + +class AuthenticationInformation { + static const usernameKey = 'username'; + static const passwordKey = 'password'; + static const tokenKey = 'token'; + static const serverUrlKey = 'serverUrl'; + static const clientCertificateKey = 'clientCertificate'; + + final String username; + final String password; + final String token; + final String serverUrl; + final ClientCertificate? clientCertificate; + + AuthenticationInformation({ + required this.username, + required this.password, + required this.token, + required this.serverUrl, + this.clientCertificate, + }); + + AuthenticationInformation.fromJson(JSON json) + : username = json[usernameKey], + password = json[passwordKey], + token = json[tokenKey], + serverUrl = json[serverUrlKey], + clientCertificate = json[clientCertificateKey] != null + ? ClientCertificate.fromJson(json[clientCertificateKey]) + : null; + + JSON toJson() { + return { + usernameKey: username, + passwordKey: password, + tokenKey: token, + serverUrlKey: serverUrl, + clientCertificateKey: clientCertificate?.toJson(), + }; + } + + bool get isValid { + return serverUrl.isNotEmpty && token.isNotEmpty; + } + + AuthenticationInformation copyWith({ + String? username, + String? password, + String? token, + String? serverUrl, + ClientCertificate? clientCertificate, + bool removeClientCertificate = false, + bool? isLocalAuthenticationEnabled, + }) { + return AuthenticationInformation( + username: username ?? this.username, + password: password ?? this.password, + token: token ?? this.token, + serverUrl: serverUrl ?? this.serverUrl, + clientCertificate: clientCertificate ?? + (removeClientCertificate ? null : this.clientCertificate), + ); + } +} diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart new file mode 100644 index 00000000..be1c4579 --- /dev/null +++ b/lib/features/login/model/client_certificate.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_paperless_mobile/core/type/json.dart'; + +class ClientCertificate { + static const bytesKey = 'bytes'; + static const passphraseKey = 'passphrase'; + + final Uint8List bytes; + final String? passphrase; + + ClientCertificate({required this.bytes, this.passphrase}); + + static ClientCertificate? nullable(Uint8List? bytes, {String? passphrase}) { + if (bytes != null) { + return ClientCertificate(bytes: bytes, passphrase: passphrase); + } + return null; + } + + JSON toJson() { + return { + bytesKey: base64Encode(bytes), + passphraseKey: passphrase, + }; + } + + ClientCertificate.fromJson(JSON json) + : bytes = base64Decode(json[bytesKey]), + passphrase = json[passphraseKey]; + + ClientCertificate copyWith({Uint8List? bytes, String? passphrase}) { + return ClientCertificate( + bytes: bytes ?? this.bytes, + passphrase: passphrase ?? this.passphrase, + ); + } +} diff --git a/lib/features/login/model/user_credentials.model.dart b/lib/features/login/model/user_credentials.model.dart new file mode 100644 index 00000000..b69e3cef --- /dev/null +++ b/lib/features/login/model/user_credentials.model.dart @@ -0,0 +1,13 @@ +class UserCredentials { + final String? username; + final String? password; + + UserCredentials({this.username, this.password}); + + UserCredentials copyWith({String? username, String? password}) { + return UserCredentials( + username: username ?? this.username, + password: password ?? this.password, + ); + } +} diff --git a/lib/features/login/services/authentication.service.dart b/lib/features/login/services/authentication.service.dart new file mode 100644 index 00000000..c2ac4a51 --- /dev/null +++ b/lib/features/login/services/authentication.service.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/core/store/local_vault.dart'; +import 'package:http/http.dart'; +import 'package:injectable/injectable.dart'; +import 'package:local_auth/local_auth.dart'; + +@singleton +class AuthenticationService { + final BaseClient httpClient; + final LocalVault localStore; + final LocalAuthentication localAuthentication; + + AuthenticationService( + this.localStore, + this.localAuthentication, + @Named("timeoutClient") this.httpClient, + ); + + /// + /// Returns the authentication token. + /// + Future login({ + required String username, + required String password, + required String serverUrl, + }) async { + final response = await httpClient.post( + Uri.parse("/api/token/"), + body: {"username": username, "password": password}, + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['token']; + } else if (response.statusCode == 400 && + response.body.toLowerCase().contains("no required certificate was sent")) { + throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration); + } else { + throw const ErrorMessage(ErrorCode.authenticationFailed); + } + } + + Future authenticateLocalUser(String localizedReason) async { + if (await localAuthentication.isDeviceSupported()) { + return await localAuthentication.authenticate( + localizedReason: localizedReason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: true, + useErrorDialogs: true, + ), + ); + } + return false; + } +} diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart new file mode 100644 index 00000000..be3eec51 --- /dev/null +++ b/lib/features/login/view/login_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:flutter_paperless_mobile/features/login/view/widgets/client_certificate_form_field.dart'; +import 'package:flutter_paperless_mobile/features/login/view/widgets/server_address_form_field.dart'; +import 'package:flutter_paperless_mobile/features/login/view/widgets/user_credentials_form_field.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({Key? key}) : super(key: key); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + + bool _isLoginLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: true, + appBar: AppBar( + title: Text(S.of(context).loginPageTitle), + bottom: _isLoginLoading + ? const PreferredSize( + preferredSize: Size(double.infinity, 4), + child: LinearProgressIndicator(), + ) + : null, + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: FormBuilder( + key: _formKey, + child: ListView( + children: [ + const ServerAddressFormField().padded(), + const UserCredentialsFormField(), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + S.of(context).loginPageAdvancedLabel, + style: Theme.of(context).textTheme.bodyText1, + ).padded(), + ), + ), + const ClientCertificateFormField(), + LayoutBuilder(builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: constraints.maxWidth, + child: _buildLoginButton(), + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildLoginButton() { + return ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.primaryContainer), + elevation: const MaterialStatePropertyAll(0), + ), + onPressed: _login, + child: Text( + S.of(context).loginPageLoginButtonLabel, + ), + ); + } + + void _login() async { + FocusScope.of(context).unfocus(); + if (_formKey.currentState?.saveAndValidate() ?? false) { + setState(() => _isLoginLoading = true); + final form = _formKey.currentState?.value; + getIt() + .login( + credentials: form?[UserCredentialsFormField.fkCredentials], + serverUrl: form?[ServerAddressFormField.fkServerAddress], + clientCertificate: form?[ClientCertificateFormField.fkClientCertificate], + ) //TODO: Move Intro slider route push here! + .onError( + (error, _) => showError(context, error), + ) + .whenComplete(() => setState(() => _isLoginLoading = false)); + } + } +} diff --git a/lib/features/login/view/widgets/client_certificate_form_field.dart b/lib/features/login/view/widgets/client_certificate_form_field.dart new file mode 100644 index 00000000..1d82956a --- /dev/null +++ b/lib/features/login/view/widgets/client_certificate_form_field.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class ClientCertificateFormField extends StatefulWidget { + static const fkClientCertificate = 'clientCertificate'; + const ClientCertificateFormField({Key? key}) : super(key: key); + + @override + State createState() => _ClientCertificateFormFieldState(); +} + +class _ClientCertificateFormFieldState extends State { + File? _selectedFile; + @override + Widget build(BuildContext context) { + return FormBuilderField( + initialValue: null, + validator: (value) { + if (value == null) { + return null; + } + assert(_selectedFile != null); + if (_selectedFile?.path.split(".").last != 'pfx') { + return S.of(context).loginPageClientCertificateSettingInvalidFileFormatValidationText; + } + return null; + }, + builder: (field) { + return ExpansionTile( + title: Text(S.of(context).loginPageClientCertificateSettingLabel), + subtitle: Text(S.of(context).loginPageClientCertificateSettingDescriptionText), + children: [ + InputDecorator( + decoration: InputDecoration( + errorText: field.errorText, + border: InputBorder.none, + ), + child: Column( + children: [ + ListTile( + leading: ElevatedButton( + onPressed: () => _onSelectFile(field), + child: Text(S.of(context).genericActionSelectText), + ), + title: _buildSelectedFileText(field), + trailing: AbsorbPointer( + absorbing: field.value == null, + child: _selectedFile != null + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _selectedFile = null; + field.didChange(null); + }), + ) + : null, + ), + ), + if (_selectedFile != null) ...[ + ObscuredInputTextFormField( + initialValue: field.value?.passphrase, + onChanged: (value) => field.didChange( + field.value?.copyWith(passphrase: value), + ), + label: S.of(context).loginPageClientCertificatePassphraseLabel, + ).padded(), + ] else + ...[] + ], + ), + ), + ], + ); + }, + name: ClientCertificateFormField.fkClientCertificate, + ); + } + + Future _onSelectFile(FormFieldState field) async { + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.single.path != null) { + File file = File(result.files.single.path!); + setState(() { + _selectedFile = file; + }); + final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ?? + ClientCertificate(bytes: file.readAsBytesSync()); + field.didChange(changedValue); + } + } + + Widget _buildSelectedFileText(FormFieldState field) { + if (field.value == null) { + assert(_selectedFile == null); + return Text( + S.of(context).loginPageClientCertificateSettingSelectFileText, + style: TextStyle(color: Theme.of(context).hintColor), + ); + } else { + assert(_selectedFile != null); + return Text( + _selectedFile!.path.split("/").last, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ); + } + } +} diff --git a/lib/features/login/view/widgets/password_text_field.dart b/lib/features/login/view/widgets/password_text_field.dart new file mode 100644 index 00000000..0fd9ee8c --- /dev/null +++ b/lib/features/login/view/widgets/password_text_field.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class ObscuredInputTextFormField extends StatefulWidget { + final String? initialValue; + final String label; + final void Function(String?) onChanged; + final FormFieldValidator? validator; + + const ObscuredInputTextFormField({ + super.key, + required this.onChanged, + required this.label, + this.validator, + this.initialValue, + }); + + @override + State createState() => _ObscuredInputTextFormFieldState(); +} + +class _ObscuredInputTextFormFieldState extends State { + bool _showPassword = false; + final FocusNode _passwordFocusNode = FocusNode(); + + @override + void dispose() { + _passwordFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: widget.validator, + initialValue: widget.initialValue, + focusNode: _passwordFocusNode, + obscureText: !_showPassword, + autocorrect: false, + onChanged: widget.onChanged, + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + label: Text(widget.label), + suffixIcon: IconButton( + icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() { + _showPassword = !_showPassword; + }), + ), + ), + ); + } +} diff --git a/lib/features/login/view/widgets/server_address_form_field.dart b/lib/features/login/view/widgets/server_address_form_field.dart new file mode 100644 index 00000000..61778674 --- /dev/null +++ b/lib/features/login/view/widgets/server_address_form_field.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class ServerAddressFormField extends StatefulWidget { + static const String fkServerAddress = "serverAddress"; + const ServerAddressFormField({ + Key? key, + }) : super(key: key); + + @override + State createState() => _ServerAddressFormFieldState(); +} + +class _ServerAddressFormFieldState extends State { + ReachabilityStatus _reachabilityStatus = ReachabilityStatus.undefined; + + @override + Widget build(BuildContext context) { + return FormBuilderTextField( + name: ServerAddressFormField.fkServerAddress, + validator: FormBuilderValidators.required( + errorText: S.of(context).loginPageServerUrlValidatorMessageText, + ), + decoration: InputDecoration( + suffixIcon: _buildIsReachableIcon(), + hintText: "http://192.168.1.50:8000", + labelText: S.of(context).loginPageServerUrlFieldLabel, + ), + onSubmitted: _updateIsAddressReachableStatus, + ); + } + + Widget? _buildIsReachableIcon() { + switch (_reachabilityStatus) { + case ReachabilityStatus.reachable: + return const Icon( + Icons.done, + color: Colors.green, + ); + case ReachabilityStatus.notReachable: + return Icon( + Icons.close, + color: Theme.of(context).colorScheme.error, + ); + case ReachabilityStatus.testing: + return const RefreshProgressIndicator(); + case ReachabilityStatus.undefined: + return null; + } + } + + void _updateIsAddressReachableStatus(String? address) async { + if (address == null || address.isEmpty) { + setState(() { + _reachabilityStatus = ReachabilityStatus.undefined; + }); + return; + } + //https://stackoverflow.com/questions/49648022/check-whether-there-is-an-internet-connection-available-on-flutter-app + setState(() => _reachabilityStatus = ReachabilityStatus.testing); + final isReachable = await getIt().isServerReachable(address); + if (isReachable) { + setState(() => _reachabilityStatus = ReachabilityStatus.reachable); + } else { + setState(() => _reachabilityStatus = ReachabilityStatus.notReachable); + } + } +} + +enum ReachabilityStatus { reachable, notReachable, testing, undefined } diff --git a/lib/features/login/view/widgets/user_credentials_form_field.dart b/lib/features/login/view/widgets/user_credentials_form_field.dart new file mode 100644 index 00000000..d799e5f0 --- /dev/null +++ b/lib/features/login/view/widgets/user_credentials_form_field.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/login/model/user_credentials.model.dart'; +import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class UserCredentialsFormField extends StatefulWidget { + static const fkCredentials = 'credentials'; + const UserCredentialsFormField({Key? key}) : super(key: key); + + @override + State createState() => + _UserCredentialsFormFieldState(); +} + +class _UserCredentialsFormFieldState extends State { + @override + Widget build(BuildContext context) { + return FormBuilderField( + name: UserCredentialsFormField.fkCredentials, + builder: (field) => AutofillGroup( + child: Column( + children: [ + TextFormField( + textCapitalization: TextCapitalization.words, + autovalidateMode: AutovalidateMode.onUserInteraction, + // USERNAME + autocorrect: false, + onChanged: (username) => field.didChange( + field.value?.copyWith(username: username) ?? + UserCredentials(username: username), + ), + validator: FormBuilderValidators.required( + errorText: S.of(context).loginPageUsernameValidatorMessageText, + ), + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + label: Text(S.of(context).loginPageUsernameLabel), + ), + ), + ObscuredInputTextFormField( + label: S.of(context).loginPagePasswordFieldLabel, + onChanged: (password) => field.didChange( + field.value?.copyWith(password: password) ?? + UserCredentials(password: password), + ), + validator: FormBuilderValidators.required( + errorText: S.of(context).loginPagePasswordValidatorMessageText, + ), + ), + ].map((child) => child.padded()).toList(), + ), + ), + ); + } +} + +/** + * AutofillGroup( + child: Column( + children: [ + FormBuilderTextField( + name: fkUsername, + focusNode: _focusNodes[fkUsername], + onSubmitted: (_) { + FocusScope.of(context).requestFocus(_focusNodes[fkPassword]); + }, + validator: FormBuilderValidators.required( + errorText: S.of(context).loginPageUsernameValidatorMessageText, + ), + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + labelText: S.of(context).loginPageUsernameLabel, + ), + ).padded(), + FormBuilderTextField( + name: fkPassword, + focusNode: _focusNodes[fkPassword], + onSubmitted: (_) { + FocusScope.of(context).unfocus(); + }, + autofillHints: const [AutofillHints.password], + validator: FormBuilderValidators.required( + errorText: S.of(context).loginPagePasswordValidatorMessageText, + ), + obscureText: true, + decoration: InputDecoration( + labelText: S.of(context).loginPagePasswordFieldLabel, + ), + ).padded(), + ], + ), + ); + */ diff --git a/lib/features/scan/bloc/document_scanner_cubit.dart b/lib/features/scan/bloc/document_scanner_cubit.dart new file mode 100644 index 00000000..d4b65a3d --- /dev/null +++ b/lib/features/scan/bloc/document_scanner_cubit.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class DocumentScannerCubit extends Cubit> { + static List initialState = []; + + DocumentScannerCubit() : super(initialState); + + void addScan(File file) => emit([...state, file]); + + void removeScan(int fileIndex) { + try { + state[fileIndex].deleteSync(); + final scans = [...state]; + scans.removeAt(fileIndex); + emit(scans); + } catch (_) { + addError(const ErrorMessage(ErrorCode.scanRemoveFailed)); + } + } + + void reset() { + for (final doc in state) { + doc.deleteSync(); + } + imageCache.clear(); + emit(initialState); + } +} diff --git a/lib/features/scan/logic/services/decode.isolate.dart b/lib/features/scan/logic/services/decode.isolate.dart new file mode 100644 index 00000000..91196be6 --- /dev/null +++ b/lib/features/scan/logic/services/decode.isolate.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'dart:isolate'; + +import 'package:image/image.dart' as im; + +typedef ImageOperationCallback = im.Image Function(im.Image); + +class DecodeParam { + final File file; + final SendPort sendPort; + final im.Image Function(im.Image) imageOperation; + DecodeParam(this.file, this.sendPort, this.imageOperation); +} + +void decodeIsolate(DecodeParam param) { + // Read an image from file (webp in this case). + // decodeImage will identify the format of the image and use the appropriate + // decoder. + var image = im.decodeImage(param.file.readAsBytesSync())!; + // Resize the image to a 120x? thumbnail (maintaining the aspect ratio). + var processed = param.imageOperation(image); + param.sendPort.send(processed); +} + +// Decode and process an image file in a separate thread (isolate) to avoid +// stalling the main UI thread. +Future processImage( + File file, + ImageOperationCallback imageOperation, +) async { + var receivePort = ReceivePort(); + + await Isolate.spawn( + decodeIsolate, + DecodeParam( + file, + receivePort.sendPort, + imageOperation, + )); + + var image = await receivePort.first as im.Image; + + return file.writeAsBytes(im.encodePng(image)); +} diff --git a/lib/features/scan/view/document_upload_page.dart b/lib/features/scan/view/document_upload_page.dart new file mode 100644 index 00000000..340997fb --- /dev/null +++ b/lib/features/scan/view/document_upload_page.dart @@ -0,0 +1,224 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/add_correspondent_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/add_document_type_page.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; +import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; + +class DocumentUploadPage extends StatefulWidget { + final Uint8List pdfBytes; + const DocumentUploadPage({ + Key? key, + required this.pdfBytes, + }) : super(key: key); + + @override + State createState() => _DocumentUploadPageState(); +} + +class _DocumentUploadPageState extends State { + static const fkFileName = "fileName"; + + static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss"); + final GlobalKey _formKey = GlobalKey(); + + Map _errors = {}; + bool _isUploadLoading = false; + + @override + void initState() { + super.initState(); + initializeDateFormatting(); //TODO: INTL (has to do with intl below) + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: true, + appBar: AppBar( + title: Text(S.of(context).documentsUploadPageTitle), + bottom: _isUploadLoading + ? const PreferredSize( + child: LinearProgressIndicator(), preferredSize: Size.fromHeight(4.0)) + : null, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _onSubmit, + label: Text(S.of(context).genericActionUploadLabel), + icon: const Icon(Icons.upload), + ), + body: SingleChildScrollView( + child: FormBuilder( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FormBuilderTextField( + autovalidateMode: AutovalidateMode.always, + name: DocumentModel.titleKey, + initialValue: "scan_${fileNameDateFormat.format(DateTime.now())}", + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + labelText: S.of(context).documentTitlePropertyLabel, + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _formKey.currentState?.fields[DocumentModel.titleKey]?.didChange(""); + _formKey.currentState?.fields[fkFileName]?.didChange(".pdf"); + }, + ), + errorText: _errors[DocumentModel.titleKey], + ), + onChanged: (value) { + final String? transformedValue = value?.replaceAll(RegExp(r"[\W_]"), "_"); + _formKey.currentState?.fields[fkFileName] + ?.didChange("${transformedValue ?? ''}.pdf"); + }, + ), + FormBuilderTextField( + autovalidateMode: AutovalidateMode.always, + readOnly: true, + enabled: false, + name: fkFileName, + decoration: InputDecoration( + labelText: S.of(context).documentUploadFileNameLabel, + ), + initialValue: "scan_${fileNameDateFormat.format(DateTime.now())}.pdf", + ), + FormBuilderDateTimePicker( + autovalidateMode: AutovalidateMode.always, + format: DateFormat("dd. MMMM yyyy"), //TODO: INTL + inputType: InputType.date, + name: DocumentModel.createdKey, + initialValue: null, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_outlined), + labelText: S.of(context).documentCreatedPropertyLabel + " *", + ), + ), + BlocBuilder>( + bloc: getIt(), //TODO: Use provider + builder: (context, state) { + return LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => BlocProvider.value( + value: BlocProvider.of(context), + child: AddDocumentTypePage(initialName: initialValue), + ), + label: S.of(context).documentDocumentTypePropertyLabel + " *", + name: DocumentModel.documentTypeKey, + state: state, + queryParameterIdBuilder: DocumentTypeQuery.fromId, + queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned, + prefixIcon: const Icon(Icons.description_outlined), + ); + }, + ), + BlocBuilder>( + bloc: getIt(), //TODO: Use provider + builder: (context, state) { + return LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => BlocProvider.value( + value: BlocProvider.of(context), + child: AddCorrespondentPage(initalValue: initialValue), + ), + label: S.of(context).documentCorrespondentPropertyLabel + " *", + name: DocumentModel.correspondentKey, + state: state, + queryParameterIdBuilder: CorrespondentQuery.fromId, + queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned, + prefixIcon: const Icon(Icons.person_outline), + ); + }, + ), + const TagFormField( + name: DocumentModel.tagsKey, + //Label: "Tags" + " *", + ), + Text( + "* " + S.of(context).uploadPageAutomaticallInferredFieldsHintText, + style: Theme.of(context).textTheme.caption, + ), + ].padded(), + ), + ), + ), + ); + } + + void _onSubmit() async { + _formKey.currentState?.save(); + if (_formKey.currentState?.validate() ?? false) { + try { + setState(() { + _isUploadLoading = true; + }); + await BlocProvider.of(context).addDocument( + widget.pdfBytes, + _formKey.currentState?.value[fkFileName], + onConsumptionFinished: (document) { + ScaffoldMessenger.of(rootScaffoldKey.currentContext!).showSnackBar( + SnackBar( + action: SnackBarAction( + onPressed: () { + getIt().reloadDocuments(); + }, + label: S.of(context).documentUploadProcessingSuccessfulReloadActionText, + ), + content: Text(S.of(context).documentUploadProcessingSuccessfulText), + ), + ); + }, + title: _formKey.currentState?.value[DocumentModel.titleKey], + documentType: + (_formKey.currentState?.value[DocumentModel.documentTypeKey] as IdQueryParameter).id, + correspondent: + (_formKey.currentState?.value[DocumentModel.correspondentKey] as IdQueryParameter).id, + tags: (_formKey.currentState?.value[DocumentModel.tagsKey] as IdsQueryParameter).ids, + createdAt: (_formKey.currentState?.value[DocumentModel.createdKey] as DateTime?), + ); + setState(() { + _isUploadLoading = false; + }); + getIt().reset(); + Navigator.pop(context); + showSnackBar(context, S.of(context).documentUploadSuccessText); + } on ErrorMessage catch (error) { + showError(context, error); + } on Map catch (errorMessages) { + setState(() => _errors = errorMessages); + } catch (other) { + showSnackBar(context, other.toString()); + } finally { + setState(() { + _isUploadLoading = true; + }); + } + } + } +} diff --git a/lib/features/scan/view/scanner_page.dart b/lib/features/scan/view/scanner_page.dart new file mode 100644 index 00000000..17a4afab --- /dev/null +++ b/lib/features/scan/view/scanner_page.dart @@ -0,0 +1,184 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:edge_detection/edge_detection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart'; +import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; +import 'package:flutter_paperless_mobile/features/scan/view/document_upload_page.dart'; +import 'package:flutter_paperless_mobile/features/scan/view/widgets/grid_image_item_widget.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:permission_handler/permission_handler.dart'; + +class ScannerPage extends StatefulWidget { + const ScannerPage({Key? key}) : super(key: key); + + @override + State createState() => _ScannerPageState(); +} + +class _ScannerPageState extends State with SingleTickerProviderStateMixin { + late final AnimationController _fabPulsingController; + late final Animation _animation; + @override + void initState() { + super.initState(); + _fabPulsingController = AnimationController(vsync: this, duration: const Duration(seconds: 1)) + ..repeat(reverse: true); + _animation = Tween(begin: 1.0, end: 1.2).animate(_fabPulsingController) + ..addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _fabPulsingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: const InfoDrawer(), + floatingActionButton: BlocBuilder>( + builder: (context, state) { + final fab = FloatingActionButton( + onPressed: () => _openDocumentScanner(context), + child: const Icon(Icons.add_a_photo_outlined), + ); + if (state.isEmpty) { + return Transform.scale( + child: fab, + scale: _animation.value, + ); + } + return fab; + }, + ), + appBar: _buildAppBar(context), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: _buildBody(), + ), + ); + } + + AppBar _buildAppBar(BuildContext context) { + return AppBar( + title: Text(S.of(context).documentScannerPageTitle), + actions: [ + BlocBuilder>( + builder: (context, state) { + return IconButton( + onPressed: state.isEmpty ? null : () => _reset(context), + icon: const Icon(Icons.delete_sweep), + tooltip: S.of(context).documentScannerPageResetButtonTooltipText, + ); + }, + ), + BlocBuilder>( + builder: (context, state) { + return IconButton( + onPressed: state.isEmpty ? null : () => _export(context), + icon: const Icon(Icons.done), + tooltip: S.of(context).documentScannerPageUploadButtonTooltip, + ); + }, + ), + ], + ); + } + + void _openDocumentScanner(BuildContext context) async { + await _requestCameraPermissions(); + final imagePath = await EdgeDetection.detectEdge; + if (imagePath == null) { + return; + } + final file = File(imagePath); + BlocProvider.of(context).addScan(file); + } + + void _export(BuildContext context) async { + final pw.Document doc = pw.Document(); + + for (var element in BlocProvider.of(context).state) { + final img = pw.MemoryImage(element.readAsBytesSync()); + doc.addPage( + pw.Page( + pageFormat: PdfPageFormat(img.width!.toDouble(), img.height!.toDouble()), + build: (context) => pw.Image(img), + ), + ); + } + final bytes = await doc.save(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: getIt(), + child: LabelBlocProvider( + child: DocumentUploadPage( + pdfBytes: bytes, + ), + ), + ), + ), + ); + } + + Widget _buildBody() { + return BlocBuilder>( + builder: (context, scans) { + if (scans.isNotEmpty) { + return _buildImageGrid(scans); + } + return Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text( + S.of(context).documentScannerPageEmptyStateText, + textAlign: TextAlign.center, + ), + ), + ); + }, + ); + } + + Widget _buildImageGrid(List scans) { + return GridView.builder( + itemCount: scans.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1 / sqrt(2), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemBuilder: (context, index) { + return GridImageItemWidget( + file: scans[index], + onDelete: () => BlocProvider.of(context).removeScan(index), + index: index, + totalNumberOfFiles: scans.length, + ); + }); + } + + void _reset(BuildContext context) { + BlocProvider.of(context).reset(); + } + + Future _requestCameraPermissions() async { + final hasPermission = await Permission.camera.isGranted; + if (!hasPermission) { + Permission.camera.request(); + } + } +} diff --git a/lib/features/scan/view/widgets/grid_image_item_widget.dart b/lib/features/scan/view/widgets/grid_image_item_widget.dart new file mode 100644 index 00000000..ec693782 --- /dev/null +++ b/lib/features/scan/view/widgets/grid_image_item_widget.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +typedef DeleteCallback = void Function(); +typedef OnImageOperation = void Function(File); + +class GridImageItemWidget extends StatefulWidget { + final File file; + final DeleteCallback onDelete; + //final OnImageOperation onImageOperation; + + final int index; + final int totalNumberOfFiles; + + const GridImageItemWidget({ + Key? key, + required this.file, + required this.onDelete, + required this.index, + required this.totalNumberOfFiles, + //required this.onImageOperation, + }) : super(key: key); + + @override + State createState() => _GridImageItemWidgetState(); +} + +class _GridImageItemWidgetState extends State { + bool isProcessing = false; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => _showImage(context), + child: _buildImageItem(context), + ); + } + + Card _buildImageItem(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Stack( + children: [ + Align(alignment: Alignment.bottomCenter, child: _buildNumbering()), + Align( + alignment: Alignment.topRight, + child: IconButton( + onPressed: widget.onDelete, + icon: const Icon(Icons.close), + ), + ), + isProcessing + ? _buildIsProcessing() + : Align( + alignment: Alignment.center, + child: AspectRatio( + aspectRatio: 4 / 3, + child: Image.file( + widget.file, + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), + ); + } + + Center _buildIsProcessing() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + CircularProgressIndicator(), + Text( + "Processing transformation...", + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + void _showImage(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: _buildNumbering(prefix: "Image"), + ), + body: PhotoView(imageProvider: FileImage(widget.file)), + ), + ), + ); + } + + Widget _buildNumbering({String? prefix}) { + return Text( + "${prefix ?? ""} ${widget.index + 1}/${widget.totalNumberOfFiles}", + ); + } +} diff --git a/lib/features/scan/view/widgets/scanner.dart b/lib/features/scan/view/widgets/scanner.dart new file mode 100644 index 00000000..18612c9c --- /dev/null +++ b/lib/features/scan/view/widgets/scanner.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +typedef OnImageScannedCallback = void Function(File); + +class ScannerWidget extends StatefulWidget { + final OnImageScannedCallback onImageScannedCallback; + const ScannerWidget({ + Key? key, + required this.onImageScannedCallback, + }) : super(key: key); + + @override + _ScannerWidgetState createState() => _ScannerWidgetState(); +} + +class _ScannerWidgetState extends State { + List documents = List.empty(growable: true); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Scan document")), + body: FutureBuilder( + future: Permission.camera.request(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.data!.isGranted) { + return Container(); + } + return const Center( + child: Text("No camera permissions, please enable in settings!"), + ); + }), + ); + } +} diff --git a/lib/features/scan/view/widgets/upload_dialog.dart b/lib/features/scan/view/widgets/upload_dialog.dart new file mode 100644 index 00000000..aafb278d --- /dev/null +++ b/lib/features/scan/view/widgets/upload_dialog.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class UploadDialog extends StatefulWidget { + const UploadDialog({ + Key? key, + }) : super(key: key); + + @override + State createState() => _UploadDialogState(); +} + +class _UploadDialogState extends State { + late TextEditingController _controller; + final _formKey = GlobalKey(); + + @override + void initState() { + final DateFormat format = DateFormat("yyyy_MM_dd_hh_mm_ss"); + final today = format.format(DateTime.now()); + _controller = TextEditingController.fromValue(TextEditingValue(text: "Scan_$today.pdf")); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Upload to paperless-ng"), + content: Form( + key: _formKey, + child: TextFormField( + controller: _controller, + validator: (text) { + if (text == null || text.isEmpty) { + return "Filename must be specified!"; + } + return null; + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + var txt = _controller.text; + if (!txt.endsWith(".pdf")) { + txt += ".pdf"; + } + Navigator.of(context).pop(txt); + }, + child: const Text("Upload"), + ), + ], + ); + } +} diff --git a/lib/features/settings/bloc/application_settings_cubit.dart b/lib/features/settings/bloc/application_settings_cubit.dart new file mode 100644 index 00000000..f6ddfb94 --- /dev/null +++ b/lib/features/settings/bloc/application_settings_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/core/store/local_vault.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class ApplicationSettingsCubit extends Cubit { + final LocalVault localVault; + + ApplicationSettingsCubit(this.localVault) : super(ApplicationSettingsState.defaultSettings); + + Future initialize() async { + final settings = + (await localVault.loadApplicationSettings()) ?? ApplicationSettingsState.defaultSettings; + emit(settings); + } + + Future setLocale(String? localeSubtag) async { + final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag); + _updateSettings(updatedSettings); + } + + Future setIsBiometricAuthenticationEnabled(bool isEnabled) async { + final updatedSettings = state.copyWith(isLocalAuthenticationEnabled: isEnabled); + _updateSettings(updatedSettings); + } + + Future _updateSettings(ApplicationSettingsState settings) async { + await localVault.storeApplicationSettings(settings); + emit(settings); + } + + Future setThemeMode(ThemeMode? selectedMode) async { + final updatedSettings = state.copyWith(preferredThemeMode: selectedMode); + _updateSettings(updatedSettings); + } +} diff --git a/lib/features/settings/model/application_settings_state.dart b/lib/features/settings/model/application_settings_state.dart new file mode 100644 index 00000000..a85996ee --- /dev/null +++ b/lib/features/settings/model/application_settings_state.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; + +/// +/// State holding the current application settings such as selected language, theme mode and more. +/// +/// +class ApplicationSettingsState { + static final defaultSettings = ApplicationSettingsState( + isLocalAuthenticationEnabled: false, + preferredLocaleSubtag: Platform.localeName.split('_').first, + preferredThemeMode: ThemeMode.system, + ); + + static const isLocalAuthenticationEnabledKey = "isLocalAuthenticationEnabled"; + static const preferredLocaleSubtagKey = "localeSubtag"; + static const preferredThemeModeKey = "preferredThemeModeKey"; + + final bool isLocalAuthenticationEnabled; + final String preferredLocaleSubtag; + + final ThemeMode preferredThemeMode; + + ApplicationSettingsState({ + required this.preferredLocaleSubtag, + required this.preferredThemeMode, + required this.isLocalAuthenticationEnabled, + }); + + JSON toJson() { + return { + isLocalAuthenticationEnabledKey: isLocalAuthenticationEnabled, + preferredLocaleSubtagKey: preferredLocaleSubtag, + preferredThemeModeKey: preferredThemeMode.index, + }; + } + + ApplicationSettingsState.fromJson(JSON json) + : isLocalAuthenticationEnabled = + json[isLocalAuthenticationEnabledKey] ?? defaultSettings.isLocalAuthenticationEnabled, + preferredLocaleSubtag = + json[preferredLocaleSubtagKey] ?? Platform.localeName.split("_").first, + preferredThemeMode = json[preferredThemeModeKey] != null + ? ThemeMode.values[(json[preferredThemeModeKey])] + : defaultSettings.preferredThemeMode; + + ApplicationSettingsState copyWith({ + bool? isLocalAuthenticationEnabled, + String? preferredLocaleSubtag, + ThemeMode? preferredThemeMode, + }) { + return ApplicationSettingsState( + isLocalAuthenticationEnabled: + isLocalAuthenticationEnabled ?? this.isLocalAuthenticationEnabled, + preferredLocaleSubtag: preferredLocaleSubtag ?? this.preferredLocaleSubtag, + preferredThemeMode: preferredThemeMode ?? this.preferredThemeMode, + ); + } +} diff --git a/lib/features/settings/view/pages/application_settings_page.dart b/lib/features/settings/view/pages/application_settings_page.dart new file mode 100644 index 00000000..14dc0c2e --- /dev/null +++ b/lib/features/settings/view/pages/application_settings_page.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/widgets/language_selection_setting.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/widgets/theme_mode_setting.dart'; + +class ApplicationSettingsPage extends StatelessWidget { + const ApplicationSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Application"), + ), + body: ListView( + children: const [ + LanguageSelectionSetting(), + ThemeModeSetting(), + ], + ), + ); + } +} diff --git a/lib/features/settings/view/pages/security_settings_page.dart b/lib/features/settings/view/pages/security_settings_page.dart new file mode 100644 index 00000000..2e81cc93 --- /dev/null +++ b/lib/features/settings/view/pages/security_settings_page.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/widgets/biometric_authentication_setting.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class SecuritySettingsPage extends StatelessWidget { + const SecuritySettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).settingsPageSecuritySettingsLabel)), + body: ListView( + children: const [ + BiometricAuthenticationSetting(), + ], + ), + ); + } +} diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart new file mode 100644 index 00000000..7aefd50c --- /dev/null +++ b/lib/features/settings/view/settings_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/pages/application_settings_page.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/pages/security_settings_page.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).appDrawerSettingsLabel), + ), + body: ListView( + children: [ + ListTile( + title: Text(S.of(context).settingsPageApplicationSettingsLabel), + subtitle: Text(S.of(context).settingsPageApplicationSettingsDescriptionText), + onTap: () => _goto(const ApplicationSettingsPage()), + ), + ListTile( + title: Text(S.of(context).settingsPageSecuritySettingsLabel), + subtitle: Text(S.of(context).settingsPageSecuritySettingsDescriptionText), + onTap: () => _goto(const SecuritySettingsPage()), + ), + ], + ), + ); + } + + void _goto(Widget page) { + Navigator.push( + context, + MaterialPageRoute( + builder: (ctxt) => BlocProvider.value( + value: BlocProvider.of(context), child: page), + maintainState: true, + ), + ); + } +} diff --git a/lib/features/settings/view/widgets/biometric_authentication_setting.dart b/lib/features/settings/view/widgets/biometric_authentication_setting.dart new file mode 100644 index 00000000..445cabcf --- /dev/null +++ b/lib/features/settings/view/widgets/biometric_authentication_setting.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class BiometricAuthenticationSetting extends StatelessWidget { + const BiometricAuthenticationSetting({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, settings) { + return SwitchListTile( + value: settings.isLocalAuthenticationEnabled, + title: Text(S.of(context).appSettingsBiometricAuthenticationLabel), + subtitle: Text(S.of(context).appSettingsBiometricAuthenticationDescriptionText), + onChanged: (val) async { + final settingsBloc = BlocProvider.of(context); + final String localizedReason = val + ? S.of(context).appSettingsEnableBiometricAuthenticationReasonText + : S.of(context).appSettingsDisableBiometricAuthenticationReasonText; + final changeValue = + await getIt().authenticateLocalUser(localizedReason); + if (changeValue) { + settingsBloc.setIsBiometricAuthenticationEnabled(val); + } + }, + ); + }, + ); + } +} diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart new file mode 100644 index 00000000..ae643d91 --- /dev/null +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class LanguageSelectionSetting extends StatefulWidget { + const LanguageSelectionSetting({super.key}); + + @override + State createState() => _LanguageSelectionSettingState(); +} + +class _LanguageSelectionSettingState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, settings) { + return ListTile( + title: Text(S.of(context).settingsPageLanguageSettingLabel), + subtitle: Text(_mapSubtagToLanguage(settings.preferredLocaleSubtag)), + onTap: () => showDialog( + context: context, + builder: (_) => RadioSettingsDialog( + title: Text(S.of(context).settingsPageLanguageSettingLabel), + options: [ + RadioOption( + value: 'en', + label: _mapSubtagToLanguage('en'), + ), + RadioOption( + value: 'de', + label: _mapSubtagToLanguage('de'), + ), + ], + initialValue: + BlocProvider.of(context).state.preferredLocaleSubtag, + ), + ).then((value) => BlocProvider.of(context).setLocale(value)), + ); + }, + ); + } + + _mapSubtagToLanguage(String subtag) { + switch (subtag) { + case 'en': + return "English"; + case 'de': + return "Deutsch"; + default: + return "English"; + } + } +} diff --git a/lib/features/settings/view/widgets/radio_settings_dialog.dart b/lib/features/settings/view/widgets/radio_settings_dialog.dart new file mode 100644 index 00000000..bc41d2a5 --- /dev/null +++ b/lib/features/settings/view/widgets/radio_settings_dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class RadioSettingsDialog extends StatefulWidget { + final List> options; + final T initialValue; + final Widget? title; + final Widget? confirmButton; + final Widget? cancelButton; + + const RadioSettingsDialog({ + super.key, + required this.options, + required this.initialValue, + this.title, + this.confirmButton, + this.cancelButton, + }); + + @override + State> createState() => _RadioSettingsDialogState(); +} + +class _RadioSettingsDialogState extends State> { + late T _groupValue; + + @override + void initState() { + super.initState(); + _groupValue = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + actions: [ + widget.confirmButton ?? + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(S.of(context).genericActionCancelLabel)), + widget.confirmButton ?? + TextButton( + onPressed: () => Navigator.pop(context, _groupValue), + child: Text(S.of(context).genericActionOkLabel)), + ], + title: widget.title, + content: Column( + mainAxisSize: MainAxisSize.min, + children: widget.options.map(_buildOptionListTile).toList(), + ), + ); + } + + Widget _buildOptionListTile(RadioOption option) => RadioListTile( + groupValue: _groupValue, + onChanged: (value) => setState(() => _groupValue = value!), + value: option.value, + title: Text(option.label), + ); +} + +class RadioOption { + final T value; + final String label; + + RadioOption({required this.value, required this.label}); +} diff --git a/lib/features/settings/view/widgets/theme_mode_setting.dart b/lib/features/settings/view/widgets/theme_mode_setting.dart new file mode 100644 index 00000000..1b8552a3 --- /dev/null +++ b/lib/features/settings/view/widgets/theme_mode_setting.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:flutter_paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; + +class ThemeModeSetting extends StatelessWidget { + const ThemeModeSetting({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, settings) { + return ListTile( + title: Text(S.of(context).settingsPageAppearanceSettingTitle), + subtitle: Text(_mapThemeModeToLocalizedString(settings.preferredThemeMode, context)), + onTap: () => showDialog( + context: context, + builder: (_) => RadioSettingsDialog( + options: [ + RadioOption( + value: ThemeMode.system, + label: S.of(context).settingsPageAppearanceSettingSystemThemeLabel, + ), + RadioOption( + value: ThemeMode.light, + label: S.of(context).settingsPageAppearanceSettingLightThemeLabel, + ), + RadioOption( + value: ThemeMode.dark, + label: S.of(context).settingsPageAppearanceSettingDarkThemeLabel, + ) + ], + initialValue: + BlocProvider.of(context).state.preferredThemeMode, + title: Text(S.of(context).settingsPageAppearanceSettingTitle), + ), + ).then((value) { + return BlocProvider.of(context).setThemeMode(value); + }), + ); + }, + ); + } + + String _mapThemeModeToLocalizedString(ThemeMode theme, BuildContext context) { + switch (theme) { + case ThemeMode.system: + return S.of(context).settingsThemeModeSystemLabel; + case ThemeMode.light: + return S.of(context).settingsThemeModeLightLabel; + case ThemeMode.dark: + return S.of(context).settingsThemeModeDarkLabel; + } + } +} diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb new file mode 100644 index 00000000..948c24c9 --- /dev/null +++ b/lib/l10n/intl_de.arb @@ -0,0 +1,171 @@ +{ + "@@locale": "de", + "documentTitlePropertyLabel": "Titel", + "documentCreatedPropertyLabel": "Erstellt Am", + "documentAddedPropertyLabel": "Hinzugefügt Am", + "documentModifiedPropertyLabel": "Geändert Am", + "documentDocumentTypePropertyLabel": "Dokumenttyp", + "documentCorrespondentPropertyLabel": "Korrespondent", + "documentStoragePathPropertyLabel": "Speicherpfad", + "documentTagsPropertyLabel": "Tags", + "documentArchiveSerialNumberPropertyLongLabel": "Archiv-Seriennummer", + "documentArchiveSerialNumberPropertyShortLabel": "ASN", + "appTitleText": "Paperless Mobile", + "bottomNavDocumentsPageLabel": "Dokumente", + "bottomNavScannerPageLabel": "Scanner", + "bottomNavLabelsPageLabel": "Kennzeichnungen", + "documentsPageTitle": "Dokumente", + "documentsFilterPageTitle": "Dokumente Filtern", + "documentsFilterPageAdvancedLabel": "Erweitert", + "documentsFilterPageDateRangeLastSevenDaysLabel": "Letzte 7 Tage", + "documentsFilterPageDateRangeLastMonthLabel": "Letzter Monat", + "documentsFilterPageDateRangeLastThreeMonthsLabel": "Letzten 3 Monate", + "documentsFilterPageDateRangeLastYearLabel": "Letztes Jahr", + "documentsFilterPageApplyFilterLabel": "Anwenden", + "documentsFilterPageResetFilterLabel": "Zurücksetzen", + "documentsFilterPageQueryOptionsTitleLabel": "Titel", + "documentsFilterPageQueryOptionsTitleAndContentLabel": "Titel & Inhalt", + "documentsFilterPageQueryOptionsExtendedLabel": "Erweitert", + "documentsFilterPageQueryOptionsAsnLabel": "ASN", + "documentsFilterPageDateRangeFieldStartLabel": "Von", + "documentsFilterPageDateRangeFieldEndLabel": "Bis", + "genericActionOkLabel": "Ok", + "genericActionCancelLabel": "Abbrechen", + "genericActionDeleteLabel": "Löschen", + "genericActionEditLabel": "Bearbeiten", + "genericActionSaveLabel": "Speichern", + "genericActionSelectText": "Auswählen", + "genericActionCreateLabel": "Erstellen", + "genericActionUploadLabel": "Hochladen", + "genericActionUpdateLabel": "Aktualisieren", + "appDrawerSettingsLabel": "Einstellungen", + "appDrawerAboutLabel": "Über", + "appDrawerAboutInfoLoadingText": "Lade Anwendungsinformationen...", + "appDrawerReportBugLabel": "Einen Fehler melden", + "appDrawerLogoutLabel": "Verbindung trennen", + "loginPageLoginButtonLabel": "Verbinden", + "loginPageTitle": "Mit Paperless verbinden", + "loginPageAdvancedLabel": "Erweiterte Einstellungen", + "loginPageServerUrlValidatorMessageText": "Server-Addresse darf nicht leer sein.", + "loginPageServerUrlFieldLabel": "Server-Adresse", + "loginPageUsernameValidatorMessageText": "Nutzername darf nicht leer sein.", + "loginPageUsernameLabel": "Nutzername", + "loginPagePasswordValidatorMessageText": "Passwort darf nicht leer sein.", + "loginPagePasswordFieldLabel": "Passwort", + "loginPageClientCertificatePassphraseLabel": "Passphrase", + "loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": "Falsche oder fehlende Zertifikatspassphrase.", + "documentDetailsPageTabOverviewLabel": "Übersicht", + "documentDetailsPageTabContentLabel": "Inhalt", + "documentDetailsPageTabMetaDataLabel": "Metadaten", + "documentsPageEmptyStateOopsText": "Ups.", + "documentsPageEmptyStateNothingHereText": "Es scheint nichts hier zu sein...", + "errorMessageUnknonwnError": "Ein unbekannter Fehler ist aufgetreten.", + "errorMessageAuthenticationFailed": "Authentifizierung fehlgeschlagen, bitte versuche es erneut.", + "errorMessageNotAuthenticated": "User is not authenticated.", + "errorMessageDocumentUploadFailed": "Dokument konnte nicht hochgeladen werden, bitte versuche es erneut.", + "errorMessageDocumentUpdateFailed": "Dokument konnte nicht aktualisiert werden, bitte versuche es erneut.", + "errorMessageDocumentDeleteFailed": "Dokument konnte nicht gelöscht werden, bitte versuche es erneut.", + "errorMessageDocumentPreviewFailed": "Vorschau konnte nicht geladen werden.", + "errorMessageDocumentAsnQueryFailed": "Archiv-Seriennummer konnte nicht zugewiesen werden.", + "errorMessageTagCreateFailed": "Tag konnte nicht erstellt werden, bitte versuche es erneut.", + "errorMessageTagLoadFailed": "Tags konnten nicht geladen werden.", + "errorMessageDocumentTypeCreateFailed": "Dokumenttyp konnte nicht erstellt werden, bitte versuche es erneut.", + "errorMessageDocumentTypeLoadFailed": "Dokumenttypen konnten nicht geladen werden, bitte versuche es erneut.", + "errorMessageCorrespondentCreateFailed": "Korrespondent konnte nicht erstellt werden, bitte versuche es erneut.", + "errorMessageCorrespondentLoadFailed": "Korrespondenten konnten nicht geladen werden.", + "errorMessageScanRemoveFailed": "Beim Löschen der Aufnahmen ist ein Fehler aufgetreten.", + "errorMessageInvalidClientCertificateConfiguration": "Ungültiges Zertifikat oder fehlende Passphrase, bitte versuche es erneut.", + "errorMessageDocumentLoadFailed": "Dokumente konnten nicht geladen werden, bitte versuche es erneut.", + "documentsPageSelectionBulkDeleteDialogTitle": "Löschen bestätigen", + "documentsPageSelectionBulkDeleteDialogWarningTextOne": "Sind Sie sicher, dass sie folgendes Dokument löschen wollen?", + "documentsPageSelectionBulkDeleteDialogWarningTextMany": "Sind Sie sicher, dass sie folgende Dokumente löschen wollen?", + "documentsPageSelectionBulkDeleteDialogContinueText": "Diese Aktion ist unwiderruflich. Möchten Sie trotzdem fortfahren?", + "documentPreviewPageTitle": "Vorschau", + "appSettingsBiometricAuthenticationLabel": "Biometrische Authentifizierung aktivieren", + "appSettingsEnableBiometricAuthenticationReasonText": "Authentifizieren, um die biometrische Authentifizierung zu aktivieren.", + "appSettingsDisableBiometricAuthenticationReasonText": "Authentifizieren, um die biometrische Authentifizierung zu deaktivieren.", + "errorMessageBulkDeleteDocumentsFailed": "Es ist ein Fehler beim massenhaften Löschen der Dokumente aufgetreten.", + "errorMessageBiotmetricsNotSupported": "Biometrische Authentifizierung wird von diesem Gerät nicht unterstützt.", + "errorMessageBiometricAuthenticationFailed": "Biometrische Authentifizierung fehlgeschlagen.", + "errorMessageDeviceOffline": "Daten konnten nicht geladen werden: Eine Verbindung zum Internet konnte nicht hergestellt werden.", + "errorMessageServerUnreachable": "Es konnte keine Verbindung zu Deinem Paperless Server hergestellt werden, ist die Instanz in Betrieb?", + "aboutDialogDevelopedByText": "Entwickelt von", + "documentsPageBulkDeleteSuccessfulText": "Das massenhafte Löschen der Dokumente war erfolgreich.", + "documentDeleteSuccessMessage": "Das Dokument wurde erfolgreich gelöscht.", + "documentsSelectedText": "ausgewählt", + "labelsPageCorrespondentsTitleText": "Korrespondenten", + "labelsPageDocumentTypesTitleText": "Dokumenttypen", + "labelsPageTagsTitleText": "Tags", + "tagColorPropertyLabel": "Farbe", + "tagInboxTagPropertyLabel": "Posteingangs-Tag", + "documentScannerPageEmptyStateText": "Es wurden noch keine Dokumente gescannt.", + "documentScannerPageTitle": "Scanner", + "documentScannerPageResetButtonTooltipText": "Alle scans löschen", + "documentScannerPageUploadButtonTooltip": "Dokument hochladen", + "addTagPageTitle": "Neuer Tag", + "addCorrespondentPageTitle": "Neuer Korrespondent", + "addDocumentTypePageTitle": "Neuer Dokumententyp", + "labelNamePropertyLabel": "Name", + "labelMatchPropertyLabel": "Zuweisungsmuster", + "labelMatchingAlgorithmPropertyLabel": "Zuweisungsalgorithmus", + "labelIsInsensivitePropertyLabel": "Groß-/Kleinschreibung irrelevant", + "linkedDocumentsPageTitle": "Referenzierte Dokumente", + "documentsUploadPageTitle": "Dokument vorbereiten", + "documentUploadFileNameLabel": "Dateiname", + "documentUploadSuccessText": "Das Dokument wurde erfolgreich hochgeladen. Verarbeite...", + "documentUploadProcessingSuccessfulText": "Das Dokument wurde erfolgreich verarbeitet.", + "documentUploadProcessingSuccessfulReloadActionText": "Neu laden", + "labelNotAssignedText": "Nicht zugewiesen", + "correspondentFormFieldSearchHintText": "Beginne zu tippen...", + "documentTypeFormFieldSearchHintText": "Beginne zu tippen...", + "tagFormFieldSearchHintText": "Beginne zu tippen...", + "documentsPageOrderByLabel": "Sortiere nach", + "documentDetailsPageAssignAsnButtonLabel": "Zuweisen", + "documentMetaDataMediaFilenamePropertyLabel": "Media-Dateiname", + "documentMetaDataChecksumLabel": "MD5-Prüfsumme Original", + "documentMetaDataOriginalFileSizeLabel": "Dateigröße Original", + "documentMetaDataOriginalMimeTypeLabel": "MIME-Typ Original", + "loginPageClientCertificateSettingLabel": "Client Zertifikat", + "loginPageClientCertificateSettingDescriptionText": "Konfiguriere Mutual TLS Authentifizierung", + "loginPageClientCertificateSettingInvalidFileFormatValidationText": "Ungültiges Zertifikatsformat, nur .pfx ist erlaubt.", + "loginPageClientCertificateSettingSelectFileText": "Datei auswählen...", + "uploadPageAutomaticallInferredFieldsHintText": "Wenn Werte für diese Felder angegeben werden, wird Paperless nicht automatisch einen Wert zuweisen. Wenn diese Felder automatisch von Paperless erkannt werden sollen, sollten die Felder leer bleiben.", + "settingsPageLanguageSettingLabel": "Sprache", + "settingsPageApplicationSettingsLabel": "Anwendung", + "settingsPageSecuritySettingsLabel": "Sicherheit", + "settingsPageApplicationSettingsDescriptionText": "Sprache und Aussehen", + "settingsPageSecuritySettingsDescriptionText": "Biometrische Authentifizierung", + "settingsPageAppearanceSettingTitle": "Aussehen", + "settingsPageAppearanceSettingSystemThemeLabel": "Benutze Sytemeinstellung", + "settingsPageAppearanceSettingLightThemeLabel": "Heller Modus", + "settingsPageAppearanceSettingDarkThemeLabel": "Dunkler Modus", + "appSettingsBiometricAuthenticationDescriptionText": "Authentifizierung beim Start der Anwendung", + "settingsThemeModeSystemLabel": "System", + "settingsThemeModeLightLabel": "Hell", + "settingsThemeModeDarkLabel": "Dunkel", + "genericMessageOfflineText": "Du bist offline. Überprüfe deine Verbindung.", + "documentDetailsPageSimilarDocumentsLabel": "Similar Documents", + "offlineWidgetText": "Es konte keine Verbindung zum Internet hergestellt werden.", + "labelsPageStoragePathTitleText": "Speicherpfade", + "addStoragePathPageTitle": "Neuer Speicherpfad", + "savedViewCreateNewLabel": "Neue Ansicht", + "savedViewNameLabel": "Name", + "savedViewShowOnDashboardLabel": "Auf Startseite zeigen", + "savedViewShowInSidebarLabel": "In Seitenleiste zeigen", + "savedViewsLabel": "Gespeicherte Ansichten", + "savedViewsEmptyStateText": "Lege Ansichten an, um Dokumente schneller zu finden.", + "savedViewCreateTooltipText": "Erstellt eine neue Ansicht basierend auf den aktuellen Filterkriterien.", + "documentsFilterPageSearchLabel": "Suche", + "documentEditPageTitle": "Dokument Bearbeiten", + "storagePathParameterDayLabel": "Tag", + "storagePathParameterYearLabel": "Jahr", + "storagePathParameterMonthLabel": "Monat", + "errorMessageSimilarQueryError": "Ähnliche Dokumente konnten nicht geladen werden.", + "errorMessageAutocompleteQueryError": "Beim automatischen Vervollständigen ist ein Fehler aufgetreten.", + "errorMessageStoragePathLoadFailed": "Speicherpfade konnten nicht geladen werden.", + "errorMessageStoragePathCreateFailed": "Speicherpfad konnte nicht erstellt werden, bitte versuche es erneut.", + "errorMessageLoadSavedViewsError": "Gespeicherte Ansichten konnten nicht geladen werden.", + "errorMessageCreateSavedViewError": "Gespeicherte Ansicht konnte nicht erstellt werden, bitte versuche es erneut.", + "errorMessageDeleteSavedViewError": "Gespeicherte Ansicht konnte nicht geklöscht werden, bitte versuche es erneut.", + "errorMessageRequestTimedOut": "Bei der Anfrage an den Server kam es zu einer Zeitüberschreitung." +} \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb new file mode 100644 index 00000000..bd253dda --- /dev/null +++ b/lib/l10n/intl_en.arb @@ -0,0 +1,172 @@ +{ + "@@locale": "en", + "documentTitlePropertyLabel": "Title", + "documentCreatedPropertyLabel": "Created At", + "documentAddedPropertyLabel": "Added At", + "documentModifiedPropertyLabel": "Modified At", + "documentDocumentTypePropertyLabel": "Document Type", + "documentCorrespondentPropertyLabel": "Correspondent", + "documentStoragePathPropertyLabel": "Storage Path", + "documentTagsPropertyLabel": "Tags", + "documentArchiveSerialNumberPropertyLongLabel": "Archive Serial Number", + "documentArchiveSerialNumberPropertyShortLabel": "ASN", + "appTitleText": "Paperless Mobile", + "bottomNavDocumentsPageLabel": "Documents", + "bottomNavScannerPageLabel": "Scanner", + "bottomNavLabelsPageLabel": "Labels", + "documentsPageTitle": "Documents", + "documentsFilterPageTitle": "Filter Documents", + "documentsFilterPageAdvancedLabel": "Advanced", + "documentsFilterPageDateRangeLastSevenDaysLabel": "Last 7 Days", + "documentsFilterPageDateRangeLastMonthLabel": "Last Month", + "documentsFilterPageDateRangeLastThreeMonthsLabel": "Last 3 Months", + "documentsFilterPageDateRangeLastYearLabel": "Last Year", + "documentsFilterPageApplyFilterLabel": "Apply", + "documentsFilterPageResetFilterLabel": "Reset", + "documentsFilterPageQueryOptionsTitleLabel": "Title", + "documentsFilterPageQueryOptionsTitleAndContentLabel": "Title & Content", + "documentsFilterPageQueryOptionsExtendedLabel": "Extended", + "documentsFilterPageQueryOptionsAsnLabel": "ASN", + "documentsFilterPageDateRangeFieldStartLabel": "From", + "documentsFilterPageDateRangeFieldEndLabel": "To", + "genericActionOkLabel": "Ok", + "genericActionCancelLabel": "Cancel", + "genericActionSelectText": "Select", + "genericActionDeleteLabel": "Delete", + "genericActionEditLabel": "Edit", + "genericActionSaveLabel": "Save", + "genericActionCreateLabel": "Create", + "genericActionUploadLabel": "Upload", + "genericActionUpdateLabel": "Update", + "appDrawerSettingsLabel": "Settings", + "appDrawerAboutLabel": "About", + "appDrawerAboutInfoLoadingText": "Retrieving application information...", + "appDrawerReportBugLabel": "Report a Bug", + "appDrawerLogoutLabel": "Disconnect", + "loginPageLoginButtonLabel": "Connect", + "loginPageTitle": "Connect to Paperless", + "loginPageAdvancedLabel": "Advanced Settings", + "loginPageServerUrlValidatorMessageText": "Server address must not be empty.", + "loginPageServerUrlFieldLabel": "Server Address", + "loginPageUsernameValidatorMessageText": "Username must not be empty.", + "loginPageUsernameLabel": "Username", + "loginPagePasswordValidatorMessageText": "Password must not be empty.", + "loginPagePasswordFieldLabel": "Password", + "loginPageClientCertificatePassphraseLabel": "Passphrase", + "loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": "Incorrect or missing certificate passphrase.", + "documentDetailsPageTabOverviewLabel": "Overview", + "documentDetailsPageTabContentLabel": "Content", + "documentDetailsPageTabMetaDataLabel": "Meta Data", + "documentsPageEmptyStateOopsText": "Oops.", + "documentsPageEmptyStateNothingHereText": "There seems to be nothing here...", + "errorMessageUnknonwnError": "An unknown error occurred.", + "errorMessageAuthenticationFailed": "Authentication failed, please try again.", + "errorMessageNotAuthenticated": "User is not authenticated.", + "errorMessageDocumentUploadFailed": "Could not upload document, please try again.", + "errorMessageDocumentUpdateFailed": "Could not update document, please try again.", + "errorMessageDocumentDeleteFailed": "Could not delete document, please try again.", + "errorMessageDocumentPreviewFailed": "Could not load document preview.", + "errorMessageDocumentAsnQueryFailed": "Could not assign archive serial number.", + "errorMessageTagCreateFailed": "Could not create tag, please try again.", + "errorMessageTagLoadFailed": "Could not load tags.", + "errorMessageDocumentTypeCreateFailed": "Could not create document, please try again.", + "errorMessageDocumentTypeLoadFailed": "Could not load document types, please try again.", + "errorMessageCorrespondentCreateFailed": "Could not create correspondent, please try again.", + "errorMessageCorrespondentLoadFailed": "Could not load correspondents.", + "errorMessageScanRemoveFailed": "An error occurred removing the scans.", + "errorMessageInvalidClientCertificateConfiguration": "Invalid certificate or missing passphrase, please try again", + "errorMessageDocumentLoadFailed": "Could not load documents, please try again.", + "documentsPageSelectionBulkDeleteDialogTitle": "Confirm Deletion", + "documentsPageSelectionBulkDeleteDialogWarningTextOne": "Are you sure you want to delete the following document?", + "documentsPageSelectionBulkDeleteDialogWarningTextMany": "Are you sure you want to delete the following documents?", + "documentsPageSelectionBulkDeleteDialogContinueText": "This action is irreversible. Do you wish to proceed anyway?", + "documentPreviewPageTitle": "Preview", + "appSettingsEnableBiometricAuthenticationReasonText": "Authenticate to enable biometric authentication", + "appSettingsDisableBiometricAuthenticationReasonText": "Authenticate to disable biometric authentication", + "errorMessageBulkDeleteDocumentsFailed": "Could not bulk delete documents.", + "errorMessageBiotmetricsNotSupported": "Biometric authentication not supported on this device.", + "errorMessageBiometricAuthenticationFailed": "Biometric authentication failed.", + "errorMessageDeviceOffline": "Could not fetch data: You are not connected to the internet.", + "errorMessageServerUnreachable": "Could not reach your Paperless server, is it up and running?", + "aboutDialogDevelopedByText": "Developed by", + "documentsPageBulkDeleteSuccessfulText": "Documents successfully deleted.", + "documentDeleteSuccessMessage": "Document successfully deleted.", + "documentsSelectedText": "selected", + "labelsPageCorrespondentsTitleText": "Correspondents", + "labelsPageDocumentTypesTitleText": "Document Types", + "labelsPageTagsTitleText": "Tags", + "tagColorPropertyLabel": "Color", + "tagInboxTagPropertyLabel": "Inbox-Tag", + "documentScannerPageEmptyStateText": "No documents scanned yet.", + "documentScannerPageTitle": "Scan", + "documentScannerPageResetButtonTooltipText": "Delete all scans", + "documentScannerPageUploadButtonTooltip": "Upload to Paperless", + "addTagPageTitle": "New Tag", + "addCorrespondentPageTitle": "New Correspondent", + "addDocumentTypePageTitle": "New Document Type", + "labelNamePropertyLabel": "Name", + "labelMatchPropertyLabel": "Match", + "labelMatchingAlgorithmPropertyLabel": "Matching Algorithm", + "labelIsInsensivitePropertyLabel": "Case Irrelevant", + "linkedDocumentsPageTitle": "Linked Documents", + "documentsUploadPageTitle": "Prepare document", + "documentUploadFileNameLabel": "File Name", + "documentUploadSuccessText": "Document successfully uploaded, processing...", + "documentUploadProcessingSuccessfulText": "Document successfully processed.", + "documentUploadProcessingSuccessfulReloadActionText": "Reload", + "labelNotAssignedText": "Not assigned", + "correspondentFormFieldSearchHintText": "Start typing...", + "documentTypeFormFieldSearchHintText": "Start typing...", + "tagFormFieldSearchHintText": "Filter tags...", + "documentsPageOrderByLabel": "Order By", + "documentDetailsPageAssignAsnButtonLabel": "Assign", + "documentMetaDataMediaFilenamePropertyLabel": "Media Filename", + "documentMetaDataChecksumLabel": "Original MD5-Checksum", + "documentMetaDataOriginalFileSizeLabel": "Original File Size", + "documentMetaDataOriginalMimeTypeLabel": "Original MIME-Type", + "loginPageClientCertificateSettingLabel": "Client Certificate", + "loginPageClientCertificateSettingDescriptionText": "Configure Mutual TLS Authentication", + "loginPageClientCertificateSettingInvalidFileFormatValidationText": "Invalid certificate format, only .pfx is allowed", + "loginPageClientCertificateSettingSelectFileText": "Select file...", + "uploadPageAutomaticallInferredFieldsHintText": "If you specify values for these fields, your paperless instance will not automatically derive a value. If you want these values to be automatically populated by your server, leave the fields blank.", + "settingsPageLanguageSettingLabel": "Language", + "settingsPageApplicationSettingsLabel": "Application", + "settingsPageSecuritySettingsLabel": "Security", + "appSettingsBiometricAuthenticationLabel": "Biometric authentication", + "settingsPageApplicationSettingsDescriptionText": "Language and visual appearance", + "settingsPageSecuritySettingsDescriptionText": "Biometric authentication", + "settingsPageAppearanceSettingTitle": "Appearance", + "settingsPageAppearanceSettingDescriptionText": "Choose your light or dark theme preference", + "settingsPageAppearanceSettingSystemThemeLabel": "Use system theme", + "settingsPageAppearanceSettingLightThemeLabel": "Light Theme", + "settingsPageAppearanceSettingDarkThemeLabel": "Dark Theme", + "appSettingsBiometricAuthenticationDescriptionText": "Authenticate on app start", + "settingsThemeModeSystemLabel": "System", + "settingsThemeModeLightLabel": "Light", + "settingsThemeModeDarkLabel": "Dark", + "genericMessageOfflineText": "You're offline. Check your connection.", + "documentDetailsPageSimilarDocumentsLabel": "Similar Documents", + "offlineWidgetText": "An internet connection could not be established.", + "labelsPageStoragePathTitleText": "Storage Paths", + "addStoragePathPageTitle": "New Storage Path", + "savedViewCreateNewLabel": "New View", + "savedViewNameLabel": "Name", + "savedViewShowOnDashboardLabel": "Show on dashboard", + "savedViewShowInSidebarLabel": "Show in sidebar", + "savedViewsLabel": "Saved Views", + "savedViewsEmptyStateText": "Create views to quickly filter your documents.", + "savedViewCreateTooltipText": "Creates a new view based on the current filter criteria.", + "documentsFilterPageSearchLabel": "Search", + "documentEditPageTitle": "Edit Document", + "storagePathParameterDayLabel": "day", + "storagePathParameterYearLabel": "year", + "storagePathParameterMonthLabel": "month", + "errorMessageSimilarQueryError": "Could not load similar documents.", + "errorMessageAutocompleteQueryError": "An error ocurred while trying to autocomplete your query.", + "errorMessageStoragePathLoadFailed": "Could not load storage paths.", + "errorMessageStoragePathCreateFailed": "Could not create storage path, please try again.", + "errorMessageLoadSavedViewsError": "Could not load saved views.", + "errorMessageCreateSavedViewError": "Could not create saved view, please try again.", + "errorMessageDeleteSavedViewError": "Could not delete saved view, please try again", + "errorMessageRequestTimedOut": "The request to the server timed out." +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..6118d555 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,156 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart'; +import 'package:flutter_paperless_mobile/core/global/http_self_signed_certificate_override.dart'; +import 'package:flutter_paperless_mobile/di_initializer.dart'; +import 'package:flutter_paperless_mobile/features/app_intro/application_intro_slideshow.dart'; +import 'package:flutter_paperless_mobile/features/home/view/home_page.dart'; +import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:flutter_paperless_mobile/features/login/view/login_page.dart'; +import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:flutter_paperless_mobile/generated/l10n.dart'; +import 'package:flutter_paperless_mobile/util.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/intl_standalone.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +void main() async { + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + Intl.systemLocale = await findSystemLocale(); + + // Required for client certificates + HttpOverrides.global = X509HttpOverrides(); + + configureDependencies(); + + kPackageInfo = await PackageInfo.fromPlatform(); + // Load application settings and stored authentication data + getIt().initialize(); + await getIt().initialize(); + await getIt().initialize(); + // Ogaylesgo + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + ], + child: BlocBuilder( + bloc: getIt(), + builder: (context, settings) { + return MaterialApp( + debugShowCheckedModeBanner: true, + title: "Paperless Mobile", + theme: ThemeData( + brightness: Brightness.light, + useMaterial3: true, + colorSchemeSeed: Colors.lightGreen, + appBarTheme: const AppBarTheme( + scrolledUnderElevation: 0.0, + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + chipTheme: ChipThemeData( + backgroundColor: Colors.lightGreen[50], + ), + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + colorSchemeSeed: Colors.lightGreen, + //primarySwatch: Colors.green, + appBarTheme: const AppBarTheme( + scrolledUnderElevation: 0.0, + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + chipTheme: ChipThemeData( + backgroundColor: Colors.green[900], + ), + ), + themeMode: settings.preferredThemeMode, + supportedLocales: const [ + Locale('en'), // Default if system locale is not available + Locale('de'), + ], + locale: Locale.fromSubtags(languageCode: settings.preferredLocaleSubtag), + localizationsDelegates: const [ + S.delegate, + FormBuilderLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: const AuthenticationWrapper(), + ); + }, + ), + ); + } +} + +class AuthenticationWrapper extends StatefulWidget { + const AuthenticationWrapper({Key? key}) : super(key: key); + + @override + State createState() => _AuthenticationWrapperState(); +} + +class _AuthenticationWrapperState extends State { + @override + void initState() { + FlutterNativeSplash.remove(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: BlocConsumer( + listener: (context, authState) { + final bool showIntroSlider = authState.isAuthenticated && !authState.wasLoginStored; + if (showIntroSlider) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ApplicationIntroSlideshow(), + fullscreenDialog: true, + ), + ); + } + }, + builder: (context, authentication) { + if (authentication.isAuthenticated) { + return const LabelBlocProvider( + child: HomePage(), + ); + } else { + return const LoginPage(); + } + }, + ), + ); + } +} diff --git a/lib/util.dart b/lib/util.dart new file mode 100644 index 00000000..92af1a66 --- /dev/null +++ b/lib/util.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart'; +import 'package:flutter_paperless_mobile/core/model/error_message.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +final dateFormat = DateFormat("yyyy-MM-dd"); +final GlobalKey rootScaffoldKey = GlobalKey(); +late PackageInfo kPackageInfo; + +void showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} + +void showError(BuildContext context, ErrorMessage error) { + showSnackBar(context, translateError(context, error.code)); +} + +bool isNotNull(dynamic value) { + return value != null; +} + +String formatDate(DateTime date) { + return dateFormat.format(date); +} + +String? formatDateNullable(DateTime? date) { + if (date == null) return null; + return dateFormat.format(date); +} + +Future writeToFile(Uint8List data) async { + Directory tempDir = await getTemporaryDirectory(); + String tempPath = tempDir.path; + var filePath = tempPath + '/file_01.tmp'; // file_01.tmp is dump file, can be anything + return (await File(filePath).writeAsBytes(data)).path; +} + +void setKeyNullable(Map data, String key, dynamic value) { + if (value != null) { + data[key] = value is String ? value : json.encode(value); + } +} + +String formatLocalDate(BuildContext context, DateTime dateTime) { + final tag = Localizations.maybeLocaleOf(context)?.toLanguageTag(); + return DateFormat.yMMMd(tag).format(dateTime); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..02cb7078 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1504 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "47.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + asn1lib: + dependency: transitive + description: + name: asn1lib + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + badges: + dependency: "direct main" + description: + name: badges + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + barcode: + dependency: transitive + description: + name: barcode + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + bloc: + dependency: transitive + description: + name: bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.1.0" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + url: "https://pub.dartlang.org" + source: hosted + version: "9.1.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.11" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.3" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.3.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.9" + connectivity_plus_linux: + dependency: transitive + description: + name: connectivity_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + connectivity_plus_macos: + dependency: transitive + description: + name: connectivity_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.6" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + connectivity_plus_web: + dependency: transitive + description: + name: connectivity_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.5" + connectivity_plus_windows: + dependency: transitive + description: + name: connectivity_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.2" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + dbus: + dependency: transitive + description: + name: dbus + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.8" + dependency_validator: + dependency: "direct dev" + description: + name: dependency_validator + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.2" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.3" + device_info_plus_linux: + dependency: transitive + description: + name: device_info_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + device_info_plus_macos: + dependency: transitive + description: + name: device_info_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + device_info_plus_web: + dependency: transitive + description: + name: device_info_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + device_info_plus_windows: + dependency: transitive + description: + name: device_info_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + dropdown_search: + dependency: transitive + description: + name: dropdown_search + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.3" + edge_detection: + dependency: "direct main" + description: + name: edge_detection + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.9" + encrypt: + dependency: transitive + description: + name: encrypt + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.1" + encrypted_shared_preferences: + dependency: "direct main" + description: + name: encrypted_shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + extension: + dependency: transitive + description: + name: extension + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.0" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.4" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.1.1" + flutter_blurhash: + dependency: transitive + description: + name: flutter_blurhash + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" + flutter_chips_input: + dependency: "direct main" + description: + name: flutter_chips_input + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_colorpicker: + dependency: transitive + description: + name: flutter_colorpicker + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + flutter_datetime_picker_bdaya: + dependency: transitive + description: + name: flutter_datetime_picker_bdaya + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + url: "https://pub.dartlang.org" + source: hosted + version: "7.5.0" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_splash: + dependency: "direct main" + description: + name: flutter_native_splash + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.11" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + flutter_rating_bar: + dependency: transitive + description: + name: flutter_rating_bar + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1+1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_touch_spin: + dependency: transitive + description: + name: flutter_touch_spin + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_typeahead: + dependency: transitive + description: + name: flutter_typeahead + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.1" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + font_awesome_flutter: + dependency: "direct main" + description: + name: font_awesome_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "10.1.0" + form_builder_extra_fields: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "33ba0a4407086275ac4357badc631be550fb3bcc" + url: "https://github.com/flutter-form-builder-ecosystem/form_builder_extra_fields.git" + source: git + version: "8.4.0" + form_builder_validators: + dependency: "direct main" + description: + name: form_builder_validators + url: "https://pub.dartlang.org" + source: hosted + version: "8.3.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.0" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.0" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_interceptor: + dependency: "direct main" + description: + name: http_interceptor + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0-beta.5" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + image: + dependency: "direct main" + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + injectable: + dependency: "direct main" + description: + name: injectable + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.3" + injectable_generator: + dependency: "direct dev" + description: + name: injectable_generator + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.4" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + intro_slider: + dependency: "direct main" + description: + name: intro_slider + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.0" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.13" + local_auth_ios: + dependency: transitive + description: + name: local_auth_ios + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.10" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + mockito: + dependency: "direct dev" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "5.3.2" + mocktail: + dependency: transitive + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + octo_image: + dependency: transitive + description: + name: octo_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.3+1" + package_info_plus_linux: + dependency: transitive + description: + name: package_info_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + package_info_plus_macos: + dependency: transitive + description: + name: package_info_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + package_info_plus_web: + dependency: transitive + description: + name: package_info_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + package_info_plus_windows: + dependency: transitive + description: + name: package_info_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.14" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.7" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + pdf: + dependency: "direct main" + description: + name: pdf + url: "https://pub.dartlang.org" + source: hosted + version: "3.8.1" + pdfx: + dependency: "direct main" + description: + name: pdfx + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + url: "https://pub.dartlang.org" + source: hosted + version: "9.2.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.2+1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.7.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.0" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + qr: + dependency: transitive + description: + name: qr + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.4" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.15" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.12" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + signature: + dependency: transitive + description: + name: signature + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + sliding_up_panel: + dependency: "direct main" + description: + name: sliding_up_panel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.7" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + sqflite: + dependency: transitive + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1+1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + sync_http: + dependency: transitive + description: + name: sync_http + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.4" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.16" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + universal_io: + dependency: transitive + description: + name: universal_io + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + universal_platform: + dependency: transitive + description: + name: universal_platform + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.17" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webdriver: + dependency: transitive + description: + name: webdriver + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.7.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+1" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.18.0 <3.0.0" + flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..399ee4b7 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,143 @@ +name: flutter_paperless_mobile +description: + Application to conveniently scan and share documents with a paperless-ng + server. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.1 + +environment: + sdk: ">=2.17.0 <3.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + get_it: ^7.2.0 + injectable: ^1.5.3 + encrypted_shared_preferences: ^3.0.0 + permission_handler: ^9.2.0 + pdf: ^3.8.1 + pdfx: ^2.3.0 + edge_detection: ^1.0.9 + path_provider: ^2.0.10 + image: ^3.1.3 + photo_view: ^0.14.0 + intl: ^0.17.0 + flutter_svg: ^1.0.3 + url_launcher: ^6.1.2 + file_picker: ^3.0.4 + web_socket_channel: ^2.2.0 + http: ^0.13.4 + http_interceptor: ^2.0.0-beta.5 + flutter_cache_manager: ^3.3.0 + cached_network_image: ^3.2.1 + shimmer: ^2.0.0 + flutter_bloc: ^8.1.1 + equatable: ^2.0.3 + flutter_form_builder: ^7.5.0 + form_builder_extra_fields: + git: + url: https://github.com/flutter-form-builder-ecosystem/form_builder_extra_fields.git + ref: main + form_builder_validators: ^8.3.0 + infinite_scroll_pagination: ^3.2.0 + flutter_chips_input: ^2.0.0 + sliding_up_panel: ^2.0.0+1 + package_info_plus: ^1.4.3+1 + font_awesome_flutter: ^10.1.0 + badges: ^2.0.3 + local_auth: ^2.1.2 + intro_slider: ^4.2.0 + connectivity_plus: ^2.3.9 + + flutter_native_splash: ^2.2.11 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + build_runner: ^2.1.11 + injectable_generator: ^1.5.3 + mockito: ^5.3.2 + bloc_test: ^9.1.0 + dependency_validator: ^3.0.0 + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^1.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec +# The following section is specific to Flutter. +flutter: + generate: true + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/logos/ + - assets/images/ + - test/fixtures/ + - test/fixtures/documents/ + - test/fixtures/correspondents/ + - test/fixtures/tags/ + - test/fixtures/preview/ + - test/fixtures/document_types/ + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages +flutter_intl: + enabled: true + +flutter_native_splash: + image: assets/logos/paperless_logo_green.png + color: "#f9f9f9" + + image_dark: assets/logos/paperless_logo_white.png + color_dark: "#181818" diff --git a/test/fixtures/correspondents/correspondents.json b/test/fixtures/correspondents/correspondents.json new file mode 100644 index 00000000..25ce5b47 --- /dev/null +++ b/test/fixtures/correspondents/correspondents.json @@ -0,0 +1,28 @@ +[{ + "id": 1, + "slug": "correspondent1", + "name": "Correspondent 1", + "match": "correspondent1", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 3 + }, + { + "id": 2, + "slug": "correspondent2", + "name": "Correspondent 2", + "match": "correspondent2", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 3 + }, + { + "id": 3, + "slug": "correspondent3", + "name": "Correspondent 3", + "match": "correspondent3", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 3 + } +] \ No newline at end of file diff --git a/test/fixtures/document_types/document_types.json b/test/fixtures/document_types/document_types.json new file mode 100644 index 00000000..3acc24ef --- /dev/null +++ b/test/fixtures/document_types/document_types.json @@ -0,0 +1,47 @@ +[ + { + "id": 1, + "slug": "rechnung", + "name": "Rechnung", + "match": "rechnung", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 5 + }, + { + "id": 2, + "slug": "vertrag", + "name": "Vertrag", + "match": "vertrag", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 1 + }, + { + "id": 3, + "slug": "finanzunterlagen", + "name": "Finanzunterlagen", + "match": "finanz", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 2 + }, + { + "id": 4, + "slug": "lieferschein", + "name": "Lieferschein", + "match": "lieferschein", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 1 + }, + { + "id": 5, + "slug": "paper", + "name": "Paper", + "match": "paper", + "matching_algorithm": 1, + "is_insensitive": true, + "document_count": 1 + } +] \ No newline at end of file diff --git a/test/fixtures/documents/documents.json b/test/fixtures/documents/documents.json new file mode 100644 index 00000000..e40d6712 --- /dev/null +++ b/test/fixtures/documents/documents.json @@ -0,0 +1,161 @@ +[{ + "id": 1, + "correspondent": 1, + "document_type": 1, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 2, + "correspondent": 1, + "document_type": 1, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 3, + "correspondent": 1, + "document_type": 1, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 4, + "correspondent": 1, + "document_type": 1, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 5, + "correspondent": 1, + "document_type": 1, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 6, + "correspondent": 1, + "document_type": 2, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 7, + "correspondent": 1, + "document_type": 3, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 8, + "correspondent": 1, + "document_type": 4, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 9, + "correspondent": 1, + "document_type": 5, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + }, + { + "id": 10, + "correspondent": 1, + "document_type": 3, + "storage_path": null, + "title": "Rechnung 1 title", + "content": "Rechnung 1 content", + "tags": [], + "created": "2020-01-15T00:00:00Z", + "created_date": "2020-01-15", + "modified": "2020-01-17T00:00:00Z", + "added": "2020-01-16T17:25:00Z", + "archive_serial_number": null, + "original_file_name": "document_1.pdf", + "archived_file_name": "document_1.pdf" + } +] \ No newline at end of file diff --git a/test/fixtures/preview/example_document_preview.png b/test/fixtures/preview/example_document_preview.png new file mode 100644 index 00000000..29997f87 Binary files /dev/null and b/test/fixtures/preview/example_document_preview.png differ diff --git a/test/fixtures/tags/tags.json b/test/fixtures/tags/tags.json new file mode 100644 index 00000000..c1f22ede --- /dev/null +++ b/test/fixtures/tags/tags.json @@ -0,0 +1,45 @@ +[{ + "id": 1, + "slug": "bar", + "name": "Bar", + "colour": 1, + "match": "bar", + "matching_algorithm": 1, + "is_insensitive": true, + "is_inbox_tag": false, + "document_count": 0 + }, + { + "id": 2, + "slug": "baz", + "name": "Baz", + "colour": 1, + "match": "baz", + "matching_algorithm": 1, + "is_insensitive": true, + "is_inbox_tag": false, + "document_count": 0 + }, + { + "id": 3, + "slug": "foo", + "name": "Foo", + "colour": 1, + "match": "foo", + "matching_algorithm": 1, + "is_insensitive": true, + "is_inbox_tag": false, + "document_count": 0 + }, + { + "id": 4, + "slug": "buzz", + "name": "Buzz", + "colour": 1, + "match": "buzz", + "matching_algorithm": 1, + "is_insensitive": true, + "is_inbox_tag": false, + "document_count": 0 + } +] \ No newline at end of file diff --git a/test/src/bloc/documents_cubit_test.dart b/test/src/bloc/documents_cubit_test.dart new file mode 100644 index 00000000..068face5 --- /dev/null +++ b/test/src/bloc/documents_cubit_test.dart @@ -0,0 +1,100 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart'; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart'; +import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart'; +import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../utils.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'documents_cubit_test.mocks.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final List documents = List.unmodifiable( + await loadCollection("test/fixtures/documents/documents.json", DocumentModel.fromJson), + ); + final List tags = List.unmodifiable( + await loadCollection("test/fixtures/tags/tags.json", Tag.fromJson), + ); + final List correspondents = List.unmodifiable( + await loadCollection( + "test/fixtures/correspondents/correspondents.json", Correspondent.fromJson), + ); + final List documentTypes = List.unmodifiable( + await loadCollection("test/fixtures/document_types/document_types.json", DocumentType.fromJson), + ); + + final MockDocumentRepository documentRepository = MockDocumentRepository(); + group("Test DocumentsCubit reloadDocuments", () { + test("Assert correct initial state", () { + expect(DocumentsCubit(documentRepository).state, DocumentsState.initial); + }); + + blocTest( + "Load documents shall emit new state containing the found documents", + setUp: () => when(documentRepository.find(any)).thenAnswer( + (_) async => PagedSearchResult( + count: 10, + next: null, + previous: null, + results: documents, + ), + ), + build: () => DocumentsCubit(documentRepository), + seed: () => DocumentsState.initial, + act: (bloc) => bloc.loadDocuments(), + expect: () => [ + DocumentsState( + isLoaded: true, + value: [ + PagedSearchResult( + count: 10, + next: null, + previous: null, + results: documents, + ), + ], + filter: DocumentFilter.initial) + ], + verify: (bloc) => verify(documentRepository.find(any)).called(1), + ); + + blocTest( + "Reload documents shall emit new state containing the same documents as before", + setUp: () => when(documentRepository.find(any)).thenAnswer( + (_) async => PagedSearchResult( + count: 10, + next: null, + previous: null, + results: documents, + ), + ), + build: () => DocumentsCubit(documentRepository), + seed: () => DocumentsState.initial, + act: (bloc) => bloc.loadDocuments(), + expect: () => [ + DocumentsState( + isLoaded: true, + value: [ + PagedSearchResult( + count: 10, + next: null, + previous: null, + results: documents, + ), + ], + filter: DocumentFilter.initial) + ], + verify: (bloc) => verify(documentRepository.find(any)).called(1), + ); + }); +} diff --git a/test/src/bloc/documents_cubit_test.mocks.dart b/test/src/bloc/documents_cubit_test.mocks.dart new file mode 100644 index 00000000..53e3340e --- /dev/null +++ b/test/src/bloc/documents_cubit_test.mocks.dart @@ -0,0 +1,293 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in flutter_paperless_mobile/test/src/bloc/documents_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; +import 'dart:typed_data' as _i7; + +import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart' + as _i2; +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart' + as _i8; +import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart' + as _i4; +import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart' + as _i3; +import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart' + as _i9; +import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDocumentModel_0 extends _i1.SmartFake implements _i2.DocumentModel { + _FakeDocumentModel_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePagedSearchResult_1 extends _i1.SmartFake + implements _i3.PagedSearchResult { + _FakePagedSearchResult_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDocumentMetaData_2 extends _i1.SmartFake + implements _i4.DocumentMetaData { + _FakeDocumentMetaData_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [DocumentRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDocumentRepository extends _i1.Mock + implements _i5.DocumentRepository { + @override + _i6.Future create( + _i7.Uint8List? documentBytes, + String? filename, { + required String? title, + int? documentType, + int? correspondent, + List? tags, + DateTime? createdAt, + }) => + (super.noSuchMethod( + Invocation.method( + #create, + [ + documentBytes, + filename, + ], + { + #title: title, + #documentType: documentType, + #correspondent: correspondent, + #tags: tags, + #createdAt: createdAt, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + @override + _i6.Future<_i2.DocumentModel> update(_i2.DocumentModel? doc) => + (super.noSuchMethod( + Invocation.method( + #update, + [doc], + ), + returnValue: _i6.Future<_i2.DocumentModel>.value(_FakeDocumentModel_0( + this, + Invocation.method( + #update, + [doc], + ), + )), + returnValueForMissingStub: + _i6.Future<_i2.DocumentModel>.value(_FakeDocumentModel_0( + this, + Invocation.method( + #update, + [doc], + ), + )), + ) as _i6.Future<_i2.DocumentModel>); + @override + _i6.Future findNextAsn() => (super.noSuchMethod( + Invocation.method( + #findNextAsn, + [], + ), + returnValue: _i6.Future.value(0), + returnValueForMissingStub: _i6.Future.value(0), + ) as _i6.Future); + @override + _i6.Future<_i3.PagedSearchResult> find(_i8.DocumentFilter? filter) => + (super.noSuchMethod( + Invocation.method( + #find, + [filter], + ), + returnValue: _i6.Future<_i3.PagedSearchResult>.value( + _FakePagedSearchResult_1( + this, + Invocation.method( + #find, + [filter], + ), + )), + returnValueForMissingStub: + _i6.Future<_i3.PagedSearchResult>.value( + _FakePagedSearchResult_1( + this, + Invocation.method( + #find, + [filter], + ), + )), + ) as _i6.Future<_i3.PagedSearchResult>); + @override + _i6.Future> findSimilar(int? docId) => + (super.noSuchMethod( + Invocation.method( + #findSimilar, + [docId], + ), + returnValue: _i6.Future>.value( + <_i9.SimilarDocumentModel>[]), + returnValueForMissingStub: + _i6.Future>.value( + <_i9.SimilarDocumentModel>[]), + ) as _i6.Future>); + @override + _i6.Future delete(_i2.DocumentModel? doc) => (super.noSuchMethod( + Invocation.method( + #delete, + [doc], + ), + returnValue: _i6.Future.value(0), + returnValueForMissingStub: _i6.Future.value(0), + ) as _i6.Future); + @override + _i6.Future<_i4.DocumentMetaData> getMetaData(_i2.DocumentModel? document) => + (super.noSuchMethod( + Invocation.method( + #getMetaData, + [document], + ), + returnValue: + _i6.Future<_i4.DocumentMetaData>.value(_FakeDocumentMetaData_2( + this, + Invocation.method( + #getMetaData, + [document], + ), + )), + returnValueForMissingStub: + _i6.Future<_i4.DocumentMetaData>.value(_FakeDocumentMetaData_2( + this, + Invocation.method( + #getMetaData, + [document], + ), + )), + ) as _i6.Future<_i4.DocumentMetaData>); + @override + _i6.Future> bulkDelete(List<_i2.DocumentModel>? models) => + (super.noSuchMethod( + Invocation.method( + #bulkDelete, + [models], + ), + returnValue: _i6.Future>.value([]), + returnValueForMissingStub: _i6.Future>.value([]), + ) as _i6.Future>); + @override + _i6.Future<_i7.Uint8List> getPreview(int? docId) => (super.noSuchMethod( + Invocation.method( + #getPreview, + [docId], + ), + returnValue: _i6.Future<_i7.Uint8List>.value(_i7.Uint8List(0)), + returnValueForMissingStub: + _i6.Future<_i7.Uint8List>.value(_i7.Uint8List(0)), + ) as _i6.Future<_i7.Uint8List>); + @override + String getThumbnailUrl(int? docId) => (super.noSuchMethod( + Invocation.method( + #getThumbnailUrl, + [docId], + ), + returnValue: '', + returnValueForMissingStub: '', + ) as String); + @override + _i6.Future<_i2.DocumentModel> waitForConsumptionFinished( + String? filename, + String? title, + ) => + (super.noSuchMethod( + Invocation.method( + #waitForConsumptionFinished, + [ + filename, + title, + ], + ), + returnValue: _i6.Future<_i2.DocumentModel>.value(_FakeDocumentModel_0( + this, + Invocation.method( + #waitForConsumptionFinished, + [ + filename, + title, + ], + ), + )), + returnValueForMissingStub: + _i6.Future<_i2.DocumentModel>.value(_FakeDocumentModel_0( + this, + Invocation.method( + #waitForConsumptionFinished, + [ + filename, + title, + ], + ), + )), + ) as _i6.Future<_i2.DocumentModel>); + @override + _i6.Future<_i7.Uint8List> download(_i2.DocumentModel? document) => + (super.noSuchMethod( + Invocation.method( + #download, + [document], + ), + returnValue: _i6.Future<_i7.Uint8List>.value(_i7.Uint8List(0)), + returnValueForMissingStub: + _i6.Future<_i7.Uint8List>.value(_i7.Uint8List(0)), + ) as _i6.Future<_i7.Uint8List>); + @override + _i6.Future> autocomplete( + String? query, [ + int? limit = 10, + ]) => + (super.noSuchMethod( + Invocation.method( + #autocomplete, + [ + query, + limit, + ], + ), + returnValue: _i6.Future>.value([]), + returnValueForMissingStub: _i6.Future>.value([]), + ) as _i6.Future>); +} diff --git a/test/src/saved_view_test.dart b/test/src/saved_view_test.dart new file mode 100644 index 00000000..85f5b8c1 --- /dev/null +++ b/test/src/saved_view_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/filter_rule.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart'; +import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Validate parsing logic from [SavedView] to [DocumentFilter]:', () { + test('Values are correctly parsed if set.', () { + expect( + SavedView.fromJson({ + "id": 1, + "name": "test_name", + "show_on_dashboard": false, + "show_in_sidebar": false, + "sort_field": SortField.created.name, + "sort_reverse": true, + "filter_rules": [ + { + 'rule_type': FilterRule.correspondentRule, + 'value': "42", + }, + { + 'rule_type': FilterRule.documentTypeRule, + 'value': "69", + }, + { + 'rule_type': FilterRule.tagRule, + 'value': "1", + }, + { + 'rule_type': FilterRule.tagRule, + 'value': "2", + }, + { + 'rule_type': FilterRule.tagRule, + 'value': "3", + }, + { + 'rule_type': FilterRule.extendedRule, + 'value': "Never gonna give you up", + }, + { + 'rule_type': FilterRule.storagePathRule, + 'value': "14", + }, + { + 'rule_type': FilterRule.createdBeforeRule, + 'value': "2022-10-27", + }, + { + 'rule_type': FilterRule.createdAfterRule, + 'value': "2022-09-27", + }, + { + 'rule_type': FilterRule.addedBeforeRule, + 'value': "2022-09-26", + }, + { + 'rule_type': FilterRule.addedAfterRule, + 'value': "2000-01-01", + } + ] + }).toDocumentFilter(), + equals( + DocumentFilter.initial.copyWith( + correspondent: const CorrespondentQuery.fromId(42), + documentType: const DocumentTypeQuery.fromId(69), + storagePath: const StoragePathQuery.fromId(14), + tags: const TagsQuery.fromIds([1, 2, 3]), + createdDateBefore: DateTime.parse("2022-10-27"), + createdDateAfter: DateTime.parse("2022-09-27"), + addedDateBefore: DateTime.parse("2022-09-26"), + addedDateAfter: DateTime.parse("2000-01-01"), + sortField: SortField.created, + sortOrder: SortOrder.ascending, + queryText: "Never gonna give you up", + queryType: QueryType.extended, + ), + ), + ); + }); + + test('Values are correctly parsed if unset.', () { + expect( + SavedView.fromJson({ + "id": 1, + "name": "test_name", + "show_on_dashboard": false, + "show_in_sidebar": false, + "sort_field": SortField.created.name, + "sort_reverse": false, + "filter_rules": [], + }).toDocumentFilter(), + equals(DocumentFilter.initial), + ); + }); + + test('Values are correctly parsed if not assigned.', () { + expect( + SavedView.fromJson({ + "id": 1, + "name": "test_name", + "show_on_dashboard": false, + "show_in_sidebar": false, + "sort_field": SortField.created.name, + "sort_reverse": false, + "filter_rules": [ + { + 'rule_type': FilterRule.correspondentRule, + 'value': null, + }, + { + 'rule_type': FilterRule.documentTypeRule, + 'value': null, + }, + { + 'rule_type': FilterRule.tagRule, + 'value': null, + }, + { + 'rule_type': FilterRule.storagePathRule, + 'value': null, + }, + ], + }).toDocumentFilter(), + equals(DocumentFilter.initial.copyWith( + correspondent: const CorrespondentQuery.notAssigned(), + documentType: const DocumentTypeQuery.notAssigned(), + storagePath: const StoragePathQuery.notAssigned(), + tags: const TagsQuery.notAssigned(), + )), + ); + }); + }); + + group('Validate parsing logic from [DocumentFilter] to [SavedView]:', () { + test('Values are correctly parsed if set.', () { + expect( + SavedView.fromDocumentFilter( + DocumentFilter( + correspondent: const CorrespondentQuery.fromId(1), + documentType: const DocumentTypeQuery.fromId(2), + storagePath: const StoragePathQuery.fromId(3), + tags: const TagsQuery.fromIds([4, 5, 6]), + sortField: SortField.added, + sortOrder: SortOrder.ascending, + addedDateAfter: DateTime.parse("2020-01-01"), + addedDateBefore: DateTime.parse("2020-03-01"), + createdDateAfter: DateTime.parse("2020-02-01"), + createdDateBefore: DateTime.parse("2020-04-01"), + queryText: "Never gonna let you down", + queryType: QueryType.title, + ), + name: "test_name", + showInSidebar: false, + showOnDashboard: false, + ), + equals( + SavedView( + name: "test_name", + showOnDashboard: false, + showInSidebar: false, + sortField: SortField.added, + sortReverse: true, + filterRules: [ + FilterRule(FilterRule.correspondentRule, "1"), + FilterRule(FilterRule.documentTypeRule, "2"), + FilterRule(FilterRule.storagePathRule, "3"), + FilterRule(FilterRule.tagRule, "4"), + FilterRule(FilterRule.tagRule, "5"), + FilterRule(FilterRule.tagRule, "6"), + FilterRule(FilterRule.addedAfterRule, "2020-01-01"), + FilterRule(FilterRule.addedBeforeRule, "2020-03-01"), + FilterRule(FilterRule.createdAfterRule, "2020-02-01"), + FilterRule(FilterRule.createdBeforeRule, "2020-04-01"), + FilterRule(FilterRule.titleRule, "Never gonna let you down"), + ], + ), + ), + ); + }); + + test('Values are correctly parsed if unset.', () { + expect( + SavedView.fromDocumentFilter( + const DocumentFilter( + correspondent: CorrespondentQuery.unset(), + documentType: DocumentTypeQuery.unset(), + storagePath: StoragePathQuery.unset(), + tags: TagsQuery.unset(), + sortField: SortField.created, + sortOrder: SortOrder.descending, + addedDateAfter: null, + addedDateBefore: null, + createdDateAfter: null, + createdDateBefore: null, + queryText: null, + ), + name: "test_name", + showInSidebar: false, + showOnDashboard: false, + ), + equals( + SavedView( + name: "test_name", + showOnDashboard: false, + showInSidebar: false, + sortField: SortField.created, + sortReverse: false, + filterRules: [], + ), + ), + ); + }); + + test('Values are correctly parsed if not assigned.', () { + expect( + SavedView.fromDocumentFilter( + const DocumentFilter( + correspondent: CorrespondentQuery.notAssigned(), + documentType: DocumentTypeQuery.notAssigned(), + storagePath: StoragePathQuery.notAssigned(), + tags: TagsQuery.notAssigned(), + sortField: SortField.created, + sortOrder: SortOrder.descending, + ), + name: "test_name", + showInSidebar: false, + showOnDashboard: false, + ), + equals( + SavedView( + name: "test_name", + showOnDashboard: false, + showInSidebar: false, + sortField: SortField.created, + sortReverse: false, + filterRules: [ + FilterRule(FilterRule.correspondentRule, null), + FilterRule(FilterRule.documentTypeRule, null), + FilterRule(FilterRule.storagePathRule, null), + FilterRule(FilterRule.tagRule, null), + ], + ), + ), + ); + }); + }); +} diff --git a/test/src/widget_test.dart b/test/src/widget_test.dart new file mode 100644 index 00000000..a9c689a1 --- /dev/null +++ b/test/src/widget_test.dart @@ -0,0 +1,12 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Login validation error fields test', (WidgetTester tester) async {}); +} diff --git a/test/utils.dart b/test/utils.dart new file mode 100644 index 00000000..711bd41a --- /dev/null +++ b/test/utils.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:flutter_paperless_mobile/core/type/json.dart'; + +Future loadOne(String filePath, T Function(JSON) transformFn, int? id) async { + if (id != null) { + final coll = await loadCollection(filePath, transformFn); + return coll.firstWhere((dynamic element) => element.id == id); + } + final String response = await rootBundle.loadString(filePath); + return transformFn(jsonDecode(response)); +} + +Future> loadCollection(String filePath, T Function(JSON) transformFn, + {int? numItems, List? ids}) async { + assert(((numItems != null) ^ (ids != null)) || (numItems == null && ids == null)); + final String response = await rootBundle.loadString(filePath); + final lst = (jsonDecode(response) as List); + final res = (jsonDecode(response) as List).map((e) => transformFn(e)).toList(); + if (ids != null) { + return res.where((dynamic element) => ids.contains(element.id)).toList(); + } + if (numItems != null && lst.length < numItems) { + throw Exception("The requested collection contains only ${lst.length} items!"); + } else { + return res.sublist(0, numItems); + } +} + +const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; +String getRandomString(int length) => String.fromCharCodes( + Iterable.generate(length, (_) => _chars.codeUnitAt(Random().nextInt(_chars.length)))); diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 00000000..b38629cc --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver();