diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79cf399 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..7fd5968 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# 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. + +version: + revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: linux + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07c272a --- /dev/null +++ b/LICENSE @@ -0,0 +1,630 @@ +Copyright (C) 2022 Anže Mavrič + +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. + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /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 0000000..6f56801 --- /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 0000000..568cf1a --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,82 @@ +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" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + 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 'si.zbe.portarius' + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. + minSdkVersion 21 + targetSdkVersion 30 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + signingConfig signingConfigs.release + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..0d467f4 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4c207f3 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/portarius/MainActivity.kt b/android/app/src/main/kotlin/com/example/portarius/MainActivity.kt new file mode 100644 index 0000000..8fd5068 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/portarius/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.portarius + +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() { +} 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 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a9bf96f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f0134e4 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..31321b0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..de16c60 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..948bbd0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/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 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..0d467f4 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..83ae220 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.1.2' + 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 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cc5527d --- /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.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/assets/icons/icon.png b/assets/icons/icon.png new file mode 100644 index 0000000..b66c642 Binary files /dev/null and b/assets/icons/icon.png differ diff --git a/lib/components/appbar/appbar.dart b/lib/components/appbar/appbar.dart new file mode 100644 index 0000000..9e3dac6 --- /dev/null +++ b/lib/components/appbar/appbar.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/components/text_components/double_text.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/style.dart'; +import 'package:provider/provider.dart'; + +class PortariusAppBar extends StatefulWidget { + const PortariusAppBar({Key? key, this.endpoint, this.title = 'portarius'}) + : super(key: key); + final Endpoint? endpoint; + final String title; + + @override + State createState() => PortariusAppBarState(); +} + +class PortariusAppBarState extends State { + @override + Widget build(BuildContext context) { + StorageManager storage = + Provider.of(context, listen: false); + StyleManager style = Provider.of(context, listen: false); + Size size = MediaQuery.of(context).size; + + /// Sort snapshots by timestamp. + List? endpoints = widget.endpoint?.snapshots; + + if (endpoints != null) { + endpoints.sort((a, b) => a.time!.compareTo(b.time!)); + } + + EndpointSnapshot? latestSnapshot = endpoints?.first; + + return SliverAppBar( + pinned: true, + primary: true, + expandedHeight: widget.endpoint == null ? null : size.height * .225, + title: widget.endpoint == null + ? Text( + widget.title, + style: Theme.of(context) + .textTheme + .headline5 + ?.copyWith(fontWeight: FontWeight.bold), + ) + : null, + centerTitle: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + actions: const [], + flexibleSpace: widget.endpoint == null + ? null + : FlexibleSpaceBar( + centerTitle: true, + expandedTitleScale: 1.5, + titlePadding: const EdgeInsets.only(bottom: 15), + background: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 3, + child: Container(), + ), + Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (latestSnapshot != null) ...[ + DoubleText( + value: latestSnapshot.runningContainerCount + .toString(), + label: 'Running'), + DoubleText( + value: latestSnapshot.stoppedContainerCount + .toString(), + label: 'Stopped'), + DoubleText( + value: latestSnapshot.imageCount.toString(), + label: 'Images'), + DoubleText( + value: latestSnapshot.volumeCount.toString(), + label: 'Volumes'), + ], + ], + ), + Flexible( + flex: 2, + child: Container(), + ), + ], + ), + ) + ], + ), + title: Text( + 'portarius', + style: Theme.of(context) + .textTheme + .headline5 + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ); + } +} diff --git a/lib/components/buttons/big_blue_button.dart b/lib/components/buttons/big_blue_button.dart new file mode 100644 index 0000000..3e456d8 --- /dev/null +++ b/lib/components/buttons/big_blue_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/models/portainer/user.dart'; + +class BigBlueButton extends StatefulWidget { + const BigBlueButton( + {Key? key, + required this.formKey, + required this.onClick, + required this.buttonTitle}) + : super(key: key); + + /// Form key + /// This key is used to validate the form + final GlobalKey formKey; + + /// On click function to be called when button is pressed + /// This function is called with the [User] object as parameter + /// If the form is invalid, the function is not called + final void Function() onClick; + + /// Text to be displayed on the button + final String buttonTitle; + + @override + State createState() => _BigBlueButtonState(); +} + +class _BigBlueButtonState extends State { + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () { + if (widget.formKey.currentState!.validate()) { + widget.onClick(); + } + }, + child: Text( + widget.buttonTitle, + style: Theme.of(context).textTheme.button, + ), + ); + } +} diff --git a/lib/components/cards/about_tile.dart b/lib/components/cards/about_tile.dart new file mode 100644 index 0000000..54cf621 --- /dev/null +++ b/lib/components/cards/about_tile.dart @@ -0,0 +1,95 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:portarius/components/cards/setting_tile.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +/// A tile that displays a aboutDialog that contains information about the app, +/// the app's author, and the app's source code. +class PortariusAboutTile extends StatelessWidget { + const PortariusAboutTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + StorageManager storage = + Provider.of(context, listen: false); + return PortariusSettingTile( + onTap: () { + showAboutDialog( + context: context, + applicationName: 'Portarius', + applicationVersion: storage.packageInfo.version, + routeSettings: const RouteSettings( + name: '/about', + ), + applicationIcon: Image.asset( + 'assets/icons/icon.png', + width: 100, + ), + applicationLegalese: '© ${DateTime.now().year} Zbejas', + children: [ + RichText( + textAlign: TextAlign.center, + text: TextSpan(children: [ + TextSpan( + text: 'Portarius', + style: Theme.of(context) + .textTheme + .bodyText1! + .copyWith(color: Theme.of(context).primaryColor), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const uri = 'https://github.com/zbejas/portarius'; + if (!await launchUrlString( + uri, + mode: LaunchMode.externalApplication, + )) { + throw 'Could not launch $uri'; + } + }, + ), + TextSpan( + text: ' is a free, open-source, ' + 'cross-platform mobile ' + 'application that allows you to ' + 'manage your Portainer sessions.', + style: Theme.of(context).textTheme.bodyText1, + ), + TextSpan( + text: '\n\nPortarius is developed and maintained by ', + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: 'Zbejas.', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).primaryColor), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const uri = 'https://github.com/zbejas/'; + if (!await launchUrlString( + uri, + mode: LaunchMode.externalApplication, + )) { + throw 'Could not launch $uri'; + } + }, + ), + TextSpan( + text: + '\n\nThis app is in no way or form related to the official Portainer project.', + style: Theme.of(context).textTheme.bodySmall, + ), + ]), + ), + ], + ); + }, + title: 'About Portarius', + subtitle: 'App info and legal jibberish', + trailing: const Icon(Icons.info), + ); + } +} diff --git a/lib/components/cards/docker_card.dart b/lib/components/cards/docker_card.dart new file mode 100644 index 0000000..48a5eb4 --- /dev/null +++ b/lib/components/cards/docker_card.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:provider/provider.dart'; + +import '../../models/docker/docker_container.dart'; + +class DockerContainerCard extends StatefulWidget { + const DockerContainerCard({ + Key? key, + required this.endpoint, + required this.onUpdate, + required this.container, + required this.onTap, + required this.onLongPress, + }) : super(key: key); + + /// The endpoint to which the container belongs. + /// This is used to know which API endpoint to use. + final Endpoint endpoint; + + /// Callback for when the user updates the container. + final Future Function()? onUpdate; + final void Function()? onTap; + final void Function()? onLongPress; + + final DockerContainer container; + + @override + State createState() => _DockerContainerCardState(); +} + +class _DockerContainerCardState extends State { + final List _isLoading = [false, false]; + + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + DockerContainer container = widget.container; + Endpoint endpoint = widget.endpoint; + Size size = MediaQuery.of(context).size; + + String name = container.names!.first.replaceRange(0, 1, ''); + + return Card( + elevation: 0, + child: GridTile( + header: Padding( + padding: const EdgeInsets.all(15), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 1, + child: Card( + child: Container( + width: 18, + height: 18, + color: container.state == 'running' + ? Theme.of(context).primaryColor + : Theme.of(context).secondaryHeaderColor, + ), + ), + ), + Flexible( + flex: 3, + fit: FlexFit.loose, + child: Text( + name, + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + flex: 1, + child: SizedBox( + width: size.height * .02, + ), + ), + ], + )), + footer: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (!_isLoading[0]) ...[ + IconButton( + tooltip: container.state == 'running' ? 'Stop' : 'Start', + icon: container.state == 'running' + ? const Icon(Icons.stop_circle_outlined) + : const Icon(Icons.play_circle_outlined), + onPressed: () async { + /// Pop up confirmation dialog. + /// If the user confirms, restart the container. + /// If the user cancels, do nothing. + if (container.state == 'running') { + if (await showDialog( + context: context, + builder: (BuildContext context) { + return _stopConfirmationDialog(container); + })) { + setState(() { + _isLoading[0] = true; + }); + bool result = await RemoteService() + .stopDockerContainer(user, endpoint, container.id); + + if (result) { + widget.onUpdate!(); + } + + setState(() { + _isLoading[0] = false; + }); + } + } else { + setState(() { + _isLoading[0] = true; + }); + bool result = await RemoteService() + .startDockerContainer(user, endpoint, container.id); + + if (result) { + widget.onUpdate!(); + } + + setState(() { + _isLoading[0] = false; + }); + } + }, + ), + ] else ...[ + Padding( + padding: EdgeInsets.all(size.height * 0.011), + child: SizedBox( + height: size.height * 0.035, + width: size.height * 0.035, + child: const CircularProgressIndicator(strokeWidth: 1.5), + ), + ), + ], + if (!_isLoading[1]) ...[ + IconButton( + tooltip: 'Restart', + icon: const Icon(Icons.restart_alt), + onPressed: () async { + /// Pop up confirmation dialog. + /// If the user confirms, restart the container. + /// If the user cancels, do nothing. + if (await showDialog( + context: context, + builder: (BuildContext context) { + return _restartConfirmationDialog(container); + })) { + setState(() { + _isLoading[1] = true; + }); + bool result = await RemoteService() + .restartDockerContainer(user, endpoint, container.id); + + if (result) { + widget.onUpdate!(); + } + + setState(() { + _isLoading[1] = false; + }); + } + }, + ), + ] else ...[ + Padding( + padding: EdgeInsets.all(size.height * 0.011), + child: SizedBox( + height: size.height * 0.035, + width: size.height * 0.035, + child: const CircularProgressIndicator(strokeWidth: 1.5), + ), + ), + ], + + /*IconButton( + icon: const Icon(Icons.keyboard_arrow_right), + onPressed: () async { + setState(() { + _isLoading[2] = true; + }); + }, + ),*/ + ], + ), + ), + child: InkWell( + onTap: () { + widget.onTap!(); + }, + onLongPress: () { + widget.onLongPress!(); + }, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 45), + child: Text( + '${container.status}', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.caption, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _restartConfirmationDialog(DockerContainer container) => AlertDialog( + title: Text('Restart ${container.names?.first.replaceRange(0, 1, '')}'), + content: Text( + 'Are you sure you want to restart ${container.names?.first.replaceRange(0, 1, '')}?'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: const Text('Restart'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ); + + Widget _stopConfirmationDialog(DockerContainer container) => AlertDialog( + title: Text('Stop ${container.names?.first.replaceRange(0, 1, '')}'), + content: Text( + 'Are you sure you want to stop ${container.names?.first.replaceRange(0, 1, '')}?'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: const Text('Stop'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ); +} diff --git a/lib/components/cards/setting_tile.dart b/lib/components/cards/setting_tile.dart new file mode 100644 index 0000000..f8f44cd --- /dev/null +++ b/lib/components/cards/setting_tile.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:provider/provider.dart'; + +class PortariusSettingTile extends StatefulWidget { + const PortariusSettingTile( + {Key? key, + required this.onTap, + required this.title, + required this.subtitle, + required this.trailing, + this.enabled = true}) + : super(key: key); + final Function onTap; + final String title; + final String subtitle; + final Widget trailing; + final bool enabled; + + @override + State createState() => _PortariusSettingTileState(); +} + +class _PortariusSettingTileState extends State { + @override + Widget build(BuildContext context) { + StorageManager storage = + Provider.of(context, listen: false); + return Card( + margin: const EdgeInsets.only(left: 15, right: 15, top: 15), + child: InkWell( + onTap: !widget.enabled + ? null + : () { + widget.onTap(); + }, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: ListTile( + enabled: widget.enabled, + title: Text(widget.title), + subtitle: Text(widget.subtitle), + trailing: widget.trailing, + ), + ), + ), + ); + } +} diff --git a/lib/components/cards/status_card.dart b/lib/components/cards/status_card.dart new file mode 100644 index 0000000..72b2062 --- /dev/null +++ b/lib/components/cards/status_card.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/models/docker/detailed_container.dart'; + +class DockerStatusCard extends StatelessWidget { + DockerStatusCard({Key? key, required this.detailedContainer}) + : super(key: key); + DetailedDockerContainer detailedContainer; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'ID: ${detailedContainer.id}', + padding: const EdgeInsets.all(5.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Status: ${detailedContainer.state!.status}', + ), + const Divider(), + Text( + 'Created: ${_parseDateTime(detailedContainer.created!.toLocal())}', + ), + const Divider(), + Text( + 'Start time: ${_parseDateTime(detailedContainer.state!.startedAt!.toLocal())}', + ), + ], + ), + ), + ), + ); + } + + String _parseDateTime(DateTime time) { + return '${time.day}.${time.month}.${time.year} ${time.hour}:${time.minute}:${time.second}'; + } +} diff --git a/lib/components/drawer/drawer.dart b/lib/components/drawer/drawer.dart new file mode 100644 index 0000000..287595e --- /dev/null +++ b/lib/components/drawer/drawer.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:portarius/utils/style.dart'; +import 'package:provider/provider.dart'; + +import '../../../models/portainer/user.dart'; +import '../../../services/storage.dart'; + +class PortariusDrawer extends StatefulWidget { + const PortariusDrawer({Key? key, required this.pageRoute}) : super(key: key); + final String pageRoute; + + @override + State createState() => _PortariusDrawerState(); +} + +class _PortariusDrawerState extends State { + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: false); + StorageManager storage = + Provider.of(context, listen: false); + StyleManager style = Provider.of(context, listen: false); + SettingsManager settings = + Provider.of(context, listen: true); + return ClipRRect( + borderRadius: const BorderRadius.horizontal(right: Radius.circular(20)), + child: Drawer( + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Padding( + padding: const EdgeInsets.only(right: 20, top: 5), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Flexible( + flex: 1, + child: Padding( + padding: EdgeInsets.only(left: 15), + child: Text( + 'portarius', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.visible, + softWrap: false, + ), + ), + ), + Flexible( + flex: 1, + child: IconButton( + tooltip: 'Change Theme', + icon: Theme.of(context).brightness == Brightness.dark + ? const Icon(Icons.light_mode) + : const Icon(Icons.dark_mode), + onPressed: () { + style.setTheme( + Theme.of(context).brightness == Brightness.dark + ? ThemeMode.light + : ThemeMode.dark); + }, + ), + ), + ], + ), + const Flexible( + flex: 1, + child: Divider( + thickness: 0.75, + indent: 20, + ), + ), + Flexible( + flex: 15, + fit: FlexFit.loose, + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: ListView( + padding: EdgeInsets.zero, + children: [ + ListTile( + enabled: true, + selected: widget.pageRoute == '/home', + title: const Text('Home'), + subtitle: const Text('Home Page'), + trailing: const Icon( + Icons.home, + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).pushReplacementNamed('/home'); + }, + ), + ListTile( + title: const Text('User management'), + subtitle: const Text('Manage your saved userdata'), + trailing: const Icon(Icons.person), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).pushReplacementNamed('/users'); + }, + ), + ListTile( + enabled: true, + selected: widget.pageRoute == '/settings', + title: const Text('Settings'), + subtitle: const Text('Adjust your settings'), + trailing: const Icon( + Icons.settings, + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context) + .pushReplacementNamed('/settings'); + }, + ), + ListTile( + enabled: true, + title: const Text('Endpoints'), + subtitle: const Text('Select your endpoint'), + trailing: FutureBuilder>( + future: RemoteService().getEndpoints(user), + builder: (context, snapshot) { + if (snapshot.hasData) { + Endpoint? pickedEndpoint = + snapshot.data!.firstWhere( + (endpoint) => + endpoint.id == settings.selectedEndpointId, + ); + + if (snapshot.data!.isEmpty) { + return const Text('No endpoints'); + } else if (snapshot.data!.length == 1) { + return Text(pickedEndpoint.name ?? 'default'); + } else if (snapshot.data != null) { + return DropdownButton( + items: [ + ...snapshot.data! + .map( + (endpoint) => DropdownMenuItem( + value: endpoint.id, + child: Text( + endpoint.name ?? 'Unknown', + ), + ), + ) + .toList(), + ], + value: pickedEndpoint.name, + onChanged: (value) { + settings.selectedEndpointId = value as int; + storage.saveEndpointId(value); + }, + ); + } else { + return const Text('No endpoints'); + } + } else if (snapshot.hasError) { + return const Text('Error'); + } else { + return const Text('Loading'); + } + }, + ), + ), + ], + ), + ), + ), + Flexible( + flex: 0, + child: Padding( + padding: const EdgeInsets.only(left: 30, right: 30), + child: ListTile( + textColor: Theme.of(context).errorColor, + iconColor: Theme.of(context).errorColor, + title: const Text('Log Out'), + trailing: const Icon( + Icons.logout, + ), + onTap: () async { + await storage.clearEndpointId(); + // ignore: use_build_context_synchronously + await user.logOutUser(context); + // ignore: use_build_context_synchronously + Navigator.of(context).pushReplacementNamed('/'); + }, + ), + ), + ), + Flexible( + flex: 0, + child: Padding( + padding: const EdgeInsets.only(left: 15, top: 5), + child: Text('Portarius v${storage.packageInfo.version}'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/lists/container_grid_list.dart b/lib/components/lists/container_grid_list.dart new file mode 100644 index 0000000..6553e25 --- /dev/null +++ b/lib/components/lists/container_grid_list.dart @@ -0,0 +1,185 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'package:portarius/components/cards/docker_card.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:provider/provider.dart'; + +import '../../models/docker/docker_container.dart'; +import '../../models/portainer/token.dart'; +import '../../services/remote.dart'; + +class ContainerGrid extends StatefulWidget { + const ContainerGrid({ + Key? key, + required this.endpoint, + }) : super(key: key); + + final Endpoint endpoint; + + @override + State createState() => _ContainerGridState(); +} + +class _ContainerGridState extends State { + List _containers = []; + bool _refreshing = false; + bool _shouldRefresh = true; + + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + Size size = MediaQuery.of(context).size; + SettingsManager settingsManager = + Provider.of(context, listen: false); + Map> mappedContainers = {}; + List nonMappedContainers = []; + + if (settingsManager.autoRefresh && !_refreshing && _shouldRefresh) { + _refreshing = true; + Future.delayed(Duration(seconds: settingsManager.autoRefreshInterval), + () { + if (mounted) { + _refreshing = false; + _getContainers(user); + } + }); + } + + if (_containers.isEmpty) { + _getContainers(user); + + return const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } else { + /// Add all 'com.docker.compose.project' containers and sort them by name. + for (DockerContainer container in _containers) { + if (container.labels != null && + container.labels!.labels + .containsKey('com.docker.compose.project')) { + if (mappedContainers[ + container.labels!.labels['com.docker.compose.project']] == + null) { + mappedContainers[ + container.labels!.labels['com.docker.compose.project']] = []; + } + mappedContainers[ + container.labels!.labels['com.docker.compose.project']] + ?.add(container); + } else { + nonMappedContainers.add(container); + } + } + + /// Add all non-mapped containers to the 'default' key. + if (nonMappedContainers.isNotEmpty) { + mappedContainers['default'] = nonMappedContainers; + } + + /// Sort the containers by name. + /// This is done to make the grid look better. + for (String key in mappedContainers.keys) { + mappedContainers[key] = mappedContainers[key]! + .map((DockerContainer container) => container) + .toList() + ..sort((DockerContainer a, DockerContainer b) => + a.names!.first.compareTo(b.names!.first)); + } + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + if (mappedContainers.isEmpty) { + return const Center( + child: Text('No containers found.'), + ); + } + String project = mappedContainers.keys.toList()[index]; + List? containers = mappedContainers[project]; + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + left: 15, + top: index == 0 ? 0 : 15, + ), + child: Text( + project, + style: Theme.of(context).textTheme.headline4, + ), + ), + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, left: 10, right: 10), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: size.width ~/ 150, + childAspectRatio: 1, + ), + itemCount: (containers as List).length, + itemBuilder: (BuildContext context, int index) { + return DockerContainerCard( + container: containers[index], + endpoint: widget.endpoint, + onUpdate: () async { + List newData = await RemoteService() + .getDockerContainerList(user, widget.endpoint); + Future.delayed(const Duration(milliseconds: 100), () { + setState(() { + _containers = newData; + }); + }); + return null; + }, + onTap: () async { + _shouldRefresh = false; + var result = await Navigator.pushNamed( + context, + '/home/container', + arguments: { + 'container': containers[index], + 'endpoint': widget.endpoint, + }, + ); + setState(() { + _shouldRefresh = true; + }); + }, + onLongPress: () { + // TODO something + }, + ); + }, + ), + ], + ); + }, + childCount: mappedContainers.keys.length, + ), + ); + } + + void _getContainers(User user) { + if (!_shouldRefresh) { + return; + } + + RemoteService().getDockerContainerList(user, widget.endpoint).then((value) { + if (value.isNotEmpty && mounted) { + setState(() { + _containers = value; + }); + } + }); + + //print('refresh: ${DateTime.now()}'); + } +} diff --git a/lib/components/text_components/double_text.dart b/lib/components/text_components/double_text.dart new file mode 100644 index 0000000..1f03e64 --- /dev/null +++ b/lib/components/text_components/double_text.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class DoubleText extends StatelessWidget { + const DoubleText({Key? key, required this.value, required this.label}) + : super(key: key); + final String value; + final String label; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(value, + style: Theme.of(context) + .textTheme + .headline5 + ?.copyWith(color: Theme.of(context).primaryColor)), + Text(label, style: Theme.of(context).textTheme.bodyText2), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..21973b1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hive/hive.dart'; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:portarius/models/portainer/token.dart'; +import 'package:portarius/pages/auth/authpage.dart'; +import 'package:portarius/pages/container/container_details.dart'; +import 'package:portarius/pages/home/home.dart'; +import 'package:portarius/pages/users/user_managment.dart'; +import 'package:portarius/pages/wrapper.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:portarius/utils/style.dart'; +import 'package:provider/provider.dart'; +import 'models/portainer/user.dart'; +import 'pages/settings/settings.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + Hive.init((await path_provider.getApplicationDocumentsDirectory()).path); + + Hive.registerAdapter(TokenAdapter()); + Hive.registerAdapter(UserAdapter()); + + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + var containsEncryptionKey = + await secureStorage.containsKey(key: 'encryptionKey'); + if (!containsEncryptionKey) { + var key = Hive.generateSecureKey(); + await secureStorage.write( + key: 'encryptionKey', value: base64UrlEncode(key)); + } + + String key = await secureStorage.read(key: 'encryptionKey') ?? ''; + + runApp(MultiProvider(providers: [ + ChangeNotifierProvider( + create: (_) => User(username: '', password: '', hostUrl: '')), + ChangeNotifierProvider( + create: (_) => StorageManager(key), + ), + ChangeNotifierProvider( + create: (_) => StyleManager(), + ), + ChangeNotifierProvider( + create: (_) => SettingsManager(), + ), + ], child: const MyApp())); +} + +class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + StorageManager storage = + Provider.of(context, listen: false); + StyleManager style = Provider.of(context, listen: true); + SettingsManager settings = + Provider.of(context, listen: false); + storage.init(context).then((_) { + settings.init(storage); + }); + + const Map _routes = { + '/users': UserManagerPage(), + '/settings': SettingsPage(), + '/home/container': ContainerDetailsPage(), + '/home': HomePage(), + '/auth': AuthPage(), + '/': Wrapper(), + }; + + return FutureBuilder( + future: style.getTheme(), + builder: (context, snapshot) { + return MaterialApp( + title: 'Portarius', + initialRoute: '/', + //routes: _routes, + onGenerateRoute: (settings) { + String pageName = settings.name ?? '/'; + Widget routeWidget = _routes[pageName] ?? const Wrapper(); + + if (pageName == '/home/container') { + return PageRouteBuilder( + settings: settings, + transitionDuration: const Duration(milliseconds: 250), + reverseTransitionDuration: const Duration(milliseconds: 150), + pageBuilder: (context, animation, secondaryAnimation) => + routeWidget, + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }, + ); + } + + return PageRouteBuilder( + settings: + settings, // Pass this to make popUntil(), pushNamedAndRemoveUntil(), works + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), + pageBuilder: (_, __, ___) => routeWidget, + transitionsBuilder: (_, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ); + }, + themeMode: snapshot.data, + + /// Generated from https://rydmike.com/flexcolorscheme/ playground. + /// This is the color scheme used in the app. + theme: FlexThemeData.light( + colors: const FlexSchemeColor( + primary: Color(0xff70a1ff), + primaryContainer: Color(0xffd0e4ff), + secondary: Color(0xffac3306), + secondaryContainer: Color(0xffffdbcf), + tertiary: Color(0xff006875), + tertiaryContainer: Color(0xff95f0ff), + appBarColor: Color(0xffffdbcf), + error: Color(0xffb00020), + ), + usedColors: 1, + surfaceMode: FlexSurfaceMode.highScaffoldLowSurface, + blendLevel: 20, + appBarOpacity: 0.95, + tooltipsMatchBackground: true, + subThemesData: const FlexSubThemesData( + blendOnLevel: 20, + blendOnColors: false, + defaultRadius: 15.0, + textButtonRadius: 15.0, + elevatedButtonRadius: 40.0, + outlinedButtonRadius: 40.0, + inputDecoratorRadius: 40.0, + fabRadius: 20.0, + cardRadius: 15.0, + popupMenuRadius: 11.0, + dialogRadius: 20.0, + timePickerDialogRadius: 20.0, + ), + useMaterial3ErrorColors: true, + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + // To use the playground font, add GoogleFonts package and uncomment + // fontFamily: GoogleFonts.notoSans().fontFamily, + ), + darkTheme: FlexThemeData.dark( + colors: const FlexSchemeColor( + primary: Color(0xff9fc9ff), + primaryContainer: Color(0xff00325b), + secondary: Color(0xffffb59d), + secondaryContainer: Color(0xff872100), + tertiary: Color(0xff86d2e1), + tertiaryContainer: Color(0xff004e59), + appBarColor: Color(0xff872100), + error: Color(0xffcf6679), + ), + usedColors: 1, + surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold, + blendLevel: 20, + appBarStyle: FlexAppBarStyle.background, + appBarOpacity: 0.90, + appBarElevation: 2.0, + surfaceTint: const Color(0xff70a1ff), + tooltipsMatchBackground: true, + subThemesData: const FlexSubThemesData( + blendOnLevel: 30, + defaultRadius: 15.0, + textButtonRadius: 15.0, + elevatedButtonRadius: 40.0, + outlinedButtonRadius: 40.0, + inputDecoratorRadius: 40.0, + fabRadius: 20.0, + cardRadius: 15.0, + popupMenuRadius: 11.0, + dialogRadius: 20.0, + timePickerDialogRadius: 20.0, + ), + useMaterial3ErrorColors: true, + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + // To use the playground font, add GoogleFonts package and uncomment + // fontFamily: GoogleFonts.notoSans().fontFamily, + ), + ); + }); + } +} diff --git a/lib/models/docker/detailed_container.dart b/lib/models/docker/detailed_container.dart new file mode 100644 index 0000000..29a8f87 --- /dev/null +++ b/lib/models/docker/detailed_container.dart @@ -0,0 +1,839 @@ +// To parse this JSON data, do +// +// final detailedDockerContainer = detailedDockerContainerFromJson(jsonString); + +import 'dart:convert'; + +DetailedDockerContainer detailedDockerContainerFromJson(String str) => + DetailedDockerContainer.fromJson(json.decode(str)); + +String detailedDockerContainerToJson(DetailedDockerContainer data) => + json.encode(data.toJson()); + +class DetailedDockerContainer { + DetailedDockerContainer({ + required this.appArmorProfile, + required this.args, + required this.config, + required this.created, + required this.driver, + required this.execIDs, + required this.hostConfig, + required this.hostnamePath, + required this.hostsPath, + required this.logPath, + required this.id, + required this.image, + required this.mountLabel, + required this.name, + required this.networkSettings, + required this.path, + required this.processLabel, + required this.resolvConfPath, + required this.restartCount, + required this.state, + required this.mounts, + }); + + String? appArmorProfile; + List? args; + Config? config; + DateTime? created; + String? driver; + List? execIDs; + HostConfig? hostConfig; + String? hostnamePath; + String? hostsPath; + String? logPath; + String? id; + String? image; + String? mountLabel; + String? name; + NetworkSettings? networkSettings; + String? path; + String? processLabel; + String? resolvConfPath; + int? restartCount; + DockerState? state; + List? mounts; + + factory DetailedDockerContainer.fromJson(Map json) => + DetailedDockerContainer( + appArmorProfile: json["AppArmorProfile"], + args: List.from(json["Args"].map((x) => x)), + config: Config.fromJson(json["Config"]), + created: DateTime.parse(json["Created"]), + driver: json["Driver"], + execIDs: List.from(json["ExecIDs"]?.map((x) => x) ?? []), + hostConfig: HostConfig.fromJson(json["HostConfig"]), + hostnamePath: json["HostnamePath"], + hostsPath: json["HostsPath"], + logPath: json["LogPath"], + id: json["Id"], + image: json["Image"], + mountLabel: json["MountLabel"], + name: json["Name"].toString().replaceRange(0, 1, ''), + networkSettings: NetworkSettings.fromJson(json["NetworkSettings"]), + path: json["Path"], + processLabel: json["ProcessLabel"], + resolvConfPath: json["ResolvConfPath"], + restartCount: json["RestartCount"], + state: + json["State"] != null ? DockerState.fromJson(json["State"]) : null, + mounts: List.from(json["Mounts"].map((x) => Mount.fromJson(x))), + ); + + Map toJson() => { + "AppArmorProfile": appArmorProfile, + "Args": List.from(args?.map((x) => x) ?? []), + "Config": config?.toJson(), + "Created": created?.toIso8601String(), + "Driver": driver, + "ExecIDs": List.from(execIDs?.map((x) => x) ?? []), + "HostConfig": hostConfig?.toJson(), + "HostnamePath": hostnamePath, + "HostsPath": hostsPath, + "LogPath": logPath, + "Id": id, + "Image": image, + "MountLabel": mountLabel, + "Name": name, + "NetworkSettings": networkSettings?.toJson(), + "Path": path, + "ProcessLabel": processLabel, + "ResolvConfPath": resolvConfPath, + "RestartCount": restartCount, + "DockerState": state?.toJson(), + "Mounts": List.from(mounts?.map((x) => x.toJson()) ?? []), + }; +} + +class Config { + Config({ + required this.attachStderr, + required this.attachStdin, + required this.attachStdout, + required this.cmd, + required this.domainname, + required this.env, + required this.healthcheck, + required this.hostname, + required this.image, + required this.labels, + required this.macAddress, + required this.networkDisabled, + required this.openStdin, + required this.stdinOnce, + required this.tty, + required this.user, + required this.volumes, + required this.workingDir, + required this.stopSignal, + required this.stopTimeout, + }); + + bool? attachStderr; + bool? attachStdin; + bool? attachStdout; + List? cmd; + String? domainname; + List? env; + Healthcheck? healthcheck; + String? hostname; + String? image; + Labels? labels; + String? macAddress; + bool? networkDisabled; + bool? openStdin; + bool? stdinOnce; + bool? tty; + String? user; + Volumes? volumes; + String? workingDir; + String? stopSignal; + int? stopTimeout; + + factory Config.fromJson(Map json) => Config( + attachStderr: json["AttachStderr"], + attachStdin: json["AttachStdin"], + attachStdout: json["AttachStdout"], + cmd: List.from(json["Cmd"]?.map((x) => x) ?? []), + domainname: json["Domainname"], + env: List.from(json["Env"].map((x) => x)), + healthcheck: json["Healthcheck"] != null + ? Healthcheck.fromJson(json["Healthcheck"]) + : null, + hostname: json["Hostname"], + image: json["Image"], + labels: Labels.fromJson(json["Labels"]), + macAddress: json["MacAddress"], + networkDisabled: json["NetworkDisabled"], + openStdin: json["OpenStdin"], + stdinOnce: json["StdinOnce"], + tty: json["Tty"], + user: json["User"], + volumes: Volumes.fromJson(json["Volumes"]), + workingDir: json["WorkingDir"], + stopSignal: json["StopSignal"], + stopTimeout: json["StopTimeout"], + ); + + Map toJson() => { + "AttachStderr": attachStderr, + "AttachStdin": attachStdin, + "AttachStdout": attachStdout, + "Cmd": List.from(cmd?.map((x) => x) ?? []), + "Domainname": domainname, + "Env": List.from(env?.map((x) => x) ?? []), + "Healthcheck": healthcheck?.toJson(), + "Hostname": hostname, + "Image": image, + "Labels": labels?.toJson(), + "MacAddress": macAddress, + "NetworkDisabled": networkDisabled, + "OpenStdin": openStdin, + "StdinOnce": stdinOnce, + "Tty": tty, + "User": user, + "Volumes": volumes?.toJson(), + "WorkingDir": workingDir, + "StopSignal": stopSignal, + "StopTimeout": stopTimeout, + }; +} + +class Healthcheck { + Healthcheck({ + required this.test, + }); + + List test; + + factory Healthcheck.fromJson(Map json) => Healthcheck( + test: List.from(json["Test"].map((x) => x)), + ); + + Map toJson() => { + "Test": List.from(test.map((x) => x)), + }; +} + +class Labels { + /// This class maps all the labels that are used in the Docker image. + + Labels({ + required this.labels, + }); + + Map labels; + + factory Labels.fromJson(Map json) { + Map tempLabels = {}; + tempLabels.addAll(json); + + return Labels( + labels: tempLabels, + ); + } + + Map toJson() => { + 'Labels': labels, + }; +} + +class Volumes { + Volumes({ + required this.volumesData, + }); + + PortBindings volumesData; + + factory Volumes.fromJson(Map json) => Volumes( + volumesData: PortBindings.fromJson(json), + ); + + Map toJson() => { + "/volumes/data": volumesData.toJson(), + }; +} + +class PortBindings { + PortBindings(); + + factory PortBindings.fromJson(Map json) => PortBindings(); + + Map toJson() => {}; +} + +class HostConfig { + HostConfig({ + required this.maximumIOps, + required this.maximumIoBps, + required this.blkioWeight, + required this.blkioWeightDevice, + required this.blkioDeviceReadBps, + required this.blkioDeviceWriteBps, + required this.blkioDeviceReadIOps, + required this.blkioDeviceWriteIOps, + required this.containerIdFile, + required this.cpusetCpus, + required this.cpusetMems, + required this.cpuPercent, + required this.cpuShares, + required this.cpuPeriod, + required this.cpuRealtimePeriod, + required this.cpuRealtimeRuntime, + required this.devices, + required this.deviceRequests, + required this.ipcMode, + required this.memory, + required this.memorySwap, + required this.memoryReservation, + required this.kernelMemory, + required this.oomKillDisable, + required this.oomScoreAdj, + required this.networkMode, + required this.pidMode, + required this.portBindings, + required this.privileged, + required this.readonlyRootfs, + required this.publishAllPorts, + required this.restartPolicy, + required this.logConfig, + required this.sysctls, + required this.ulimits, + required this.volumeDriver, + required this.shmSize, + }); + + int? maximumIOps; + int? maximumIoBps; + int? blkioWeight; + List? blkioWeightDevice; + List? blkioDeviceReadBps; + List? blkioDeviceWriteBps; + List? blkioDeviceReadIOps; + List? blkioDeviceWriteIOps; + String? containerIdFile; + String? cpusetCpus; + String? cpusetMems; + int? cpuPercent; + int? cpuShares; + int? cpuPeriod; + int? cpuRealtimePeriod; + int? cpuRealtimeRuntime; + List? devices; + List? deviceRequests; + String? ipcMode; + int? memory; + int? memorySwap; + int? memoryReservation; + int? kernelMemory; + bool? oomKillDisable; + int? oomScoreAdj; + String? networkMode; + String? pidMode; + PortBindings? portBindings; + bool? privileged; + bool? readonlyRootfs; + bool? publishAllPorts; + RestartPolicy? restartPolicy; + LogConfig? logConfig; + Sysctls? sysctls; + List? ulimits; + String? volumeDriver; + int? shmSize; + + factory HostConfig.fromJson(Map json) => HostConfig( + maximumIOps: json["MaximumIOps"], + maximumIoBps: json["MaximumIOBps"], + blkioWeight: json["BlkioWeight"], + blkioWeightDevice: List.from( + json["BlkioWeightDevice"]?.map((x) => PortBindings.fromJson(x)) ?? + []), + blkioDeviceReadBps: List.from( + json["BlkioDeviceReadBps"]?.map((x) => PortBindings.fromJson(x)) ?? + []), + blkioDeviceWriteBps: List.from( + json["BlkioDeviceWriteBps"]?.map((x) => PortBindings.fromJson(x)) ?? + []), + blkioDeviceReadIOps: List.from( + json["BlkioDeviceReadIOps"]?.map((x) => PortBindings.fromJson(x)) ?? + []), + blkioDeviceWriteIOps: List.from( + json["BlkioDeviceWriteIOps"] + ?.map((x) => PortBindings.fromJson(x)) ?? + []), + containerIdFile: json["ContainerIDFile"], + cpusetCpus: json["CpusetCpus"], + cpusetMems: json["CpusetMems"], + cpuPercent: json["CpuPercent"], + cpuShares: json["CpuShares"], + cpuPeriod: json["CpuPeriod"], + cpuRealtimePeriod: json["CpuRealtimePeriod"], + cpuRealtimeRuntime: json["CpuRealtimeRuntime"], + devices: List.from(json["Devices"]?.map((x) => x) ?? []), + deviceRequests: List.from( + json["DeviceRequests"]?.map((x) => DeviceRequest.fromJson(x)) ?? + []), + ipcMode: json["IpcMode"], + memory: json["Memory"], + memorySwap: json["MemorySwap"], + memoryReservation: json["MemoryReservation"], + kernelMemory: json["KernelMemory"], + oomKillDisable: json["OomKillDisable"], + oomScoreAdj: json["OomScoreAdj"], + networkMode: json["NetworkMode"], + pidMode: json["PidMode"], + portBindings: PortBindings.fromJson(json["PortBindings"]), + privileged: json["Privileged"], + readonlyRootfs: json["ReadonlyRootfs"], + publishAllPorts: json["PublishAllPorts"], + restartPolicy: RestartPolicy.fromJson(json["RestartPolicy"]), + logConfig: LogConfig.fromJson(json["LogConfig"]), + sysctls: + json["Sysctls"] != null ? Sysctls.fromJson(json["Sysctls"]) : null, + ulimits: List.from( + json["Ulimits"]?.map((x) => PortBindings.fromJson(x)) ?? []), + volumeDriver: json["VolumeDriver"], + shmSize: json["ShmSize"], + ); + + Map toJson() => { + "MaximumIOps": maximumIOps, + "MaximumIOBps": maximumIoBps, + "BlkioWeight": blkioWeight, + "BlkioWeightDevice": + List.from(blkioWeightDevice?.map((x) => x.toJson()) ?? []), + "BlkioDeviceReadBps": List.from( + blkioDeviceReadBps?.map((x) => x.toJson()) ?? []), + "BlkioDeviceWriteBps": List.from( + blkioDeviceWriteBps?.map((x) => x.toJson()) ?? []), + "BlkioDeviceReadIOps": List.from( + blkioDeviceReadIOps?.map((x) => x.toJson()) ?? []), + "BlkioDeviceWriteIOps": List.from( + blkioDeviceWriteIOps?.map((x) => x.toJson()) ?? []), + "ContainerIDFile": containerIdFile, + "CpusetCpus": cpusetCpus, + "CpusetMems": cpusetMems, + "CpuPercent": cpuPercent, + "CpuShares": cpuShares, + "CpuPeriod": cpuPeriod, + "CpuRealtimePeriod": cpuRealtimePeriod, + "CpuRealtimeRuntime": cpuRealtimeRuntime, + "Devices": List.from(devices?.map((x) => x) ?? []), + "DeviceRequests": + List.from(deviceRequests?.map((x) => x.toJson()) ?? []), + "IpcMode": ipcMode, + "Memory": memory, + "MemorySwap": memorySwap, + "MemoryReservation": memoryReservation, + "KernelMemory": kernelMemory, + "OomKillDisable": oomKillDisable, + "OomScoreAdj": oomScoreAdj, + "NetworkMode": networkMode, + "PidMode": pidMode, + "PortBindings": portBindings?.toJson(), + "Privileged": privileged, + "ReadonlyRootfs": readonlyRootfs, + "PublishAllPorts": publishAllPorts, + "RestartPolicy": restartPolicy?.toJson(), + "LogConfig": logConfig?.toJson(), + "Sysctls": sysctls?.toJson(), + "Ulimits": List.from(ulimits?.map((x) => x.toJson()) ?? []), + "VolumeDriver": volumeDriver, + "ShmSize": shmSize, + }; +} + +class DeviceRequest { + DeviceRequest({ + required this.driver, + required this.count, + required this.deviceIDs, + required this.capabilities, + required this.options, + }); + + String? driver; + int? count; + List? deviceIDs; + List>? capabilities; + Options? options; + + factory DeviceRequest.fromJson(Map json) => DeviceRequest( + driver: json["Driver"], + count: json["Count"], + deviceIDs: List.from(json["DeviceIDs"].map((x) => x)), + capabilities: List>.from(json["Capabilities"] + .map((x) => List.from(x.map((x) => x)))), + options: Options.fromJson(json["Options"]), + ); + + Map toJson() => { + "Driver": driver, + "Count": count, + "DeviceIDs\"": List.from(deviceIDs?.map((x) => x) ?? []), + "Capabilities": List.from( + capabilities?.map((x) => List.from(x.map((x) => x))) ?? + []), + "Options": options?.toJson(), + }; +} + +class Options { + Options({ + required this.properties, + }); + + Map properties; + + factory Options.fromJson(Map json) => Options( + properties: json, + ); + + Map toJson() => properties; +} + +class LogConfig { + LogConfig({ + required this.type, + }); + + String type; + + factory LogConfig.fromJson(Map json) => LogConfig( + type: json["Type"], + ); + + Map toJson() => { + "Type": type, + }; +} + +class RestartPolicy { + RestartPolicy({ + required this.maximumRetryCount, + required this.name, + }); + + int maximumRetryCount; + String name; + + factory RestartPolicy.fromJson(Map json) => RestartPolicy( + maximumRetryCount: json["MaximumRetryCount"], + name: json["Name"], + ); + + Map toJson() => { + "MaximumRetryCount": maximumRetryCount, + "Name": name, + }; +} + +class Sysctls { + Sysctls({ + required this.netIpv4IpForward, + }); + + String netIpv4IpForward; + + factory Sysctls.fromJson(Map json) => Sysctls( + netIpv4IpForward: json["net.ipv4.ip_forward"], + ); + + Map toJson() => { + "net.ipv4.ip_forward": netIpv4IpForward, + }; +} + +class Mount { + Mount({ + required this.name, + required this.source, + required this.destination, + required this.driver, + required this.mode, + required this.rw, + required this.propagation, + }); + + String? name; + String? source; + String? destination; + String? driver; + String? mode; + bool? rw; + String? propagation; + + factory Mount.fromJson(Map json) => Mount( + name: json["Name"], + source: json["Source"], + destination: json["Destination"], + driver: json["Driver"], + mode: json["Mode"], + rw: json["RW"], + propagation: json["Propagation"], + ); + + Map toJson() => { + "Name": name, + "Source": source, + "Destination": destination, + "Driver": driver, + "Mode": mode, + "RW": rw, + "Propagation": propagation, + }; +} + +class NetworkSettings { + NetworkSettings({ + required this.bridge, + required this.sandboxId, + required this.hairpinMode, + required this.linkLocalIPv6Address, + required this.linkLocalIPv6PrefixLen, + required this.sandboxKey, + required this.endpointId, + required this.gateway, + required this.globalIPv6Address, + required this.globalIPv6PrefixLen, + required this.ipAddress, + required this.ipPrefixLen, + required this.iPv6Gateway, + required this.macAddress, + required this.networks, + }); + + String? bridge; + String? sandboxId; + bool? hairpinMode; + String? linkLocalIPv6Address; + int? linkLocalIPv6PrefixLen; + String? sandboxKey; + String? endpointId; + String? gateway; + String? globalIPv6Address; + int? globalIPv6PrefixLen; + String? ipAddress; + int? ipPrefixLen; + String? iPv6Gateway; + String? macAddress; + Networks? networks; + + factory NetworkSettings.fromJson(Map json) => + NetworkSettings( + bridge: json["Bridge"], + sandboxId: json["SandboxID"], + hairpinMode: json["HairpinMode"], + linkLocalIPv6Address: json["LinkLocalIPv6Address"], + linkLocalIPv6PrefixLen: json["LinkLocalIPv6PrefixLen"], + sandboxKey: json["SandboxKey"], + endpointId: json["EndpointID"], + gateway: json["Gateway"], + globalIPv6Address: json["GlobalIPv6Address"], + globalIPv6PrefixLen: json["GlobalIPv6PrefixLen"], + ipAddress: json["IPAddress"], + ipPrefixLen: json["IPPrefixLen"], + iPv6Gateway: json["IPv6Gateway"], + macAddress: json["MacAddress"], + networks: json["Networks"] != null + ? Networks.fromJson(json["Networks"]) + : null, + ); + + Map toJson() => { + "Bridge": bridge, + "SandboxID": sandboxId, + "HairpinMode": hairpinMode, + "LinkLocalIPv6Address": linkLocalIPv6Address, + "LinkLocalIPv6PrefixLen": linkLocalIPv6PrefixLen, + "SandboxKey": sandboxKey, + "EndpointID": endpointId, + "Gateway": gateway, + "GlobalIPv6Address": globalIPv6Address, + "GlobalIPv6PrefixLen": globalIPv6PrefixLen, + "IPAddress": ipAddress, + "IPPrefixLen": ipPrefixLen, + "IPv6Gateway": iPv6Gateway, + "MacAddress": macAddress, + "Networks": networks?.toJson(), + }; +} + +class Networks { + Networks({ + required this.bridge, + }); + + Bridge? bridge; + + factory Networks.fromJson(Map json) => Networks( + bridge: json["bridge"] != null ? Bridge.fromJson(json["bridge"]) : null, + ); + + Map toJson() => { + "bridge": bridge?.toJson(), + }; +} + +class Bridge { + Bridge({ + required this.networkId, + required this.endpointId, + required this.gateway, + required this.ipAddress, + required this.ipPrefixLen, + required this.iPv6Gateway, + required this.globalIPv6Address, + required this.globalIPv6PrefixLen, + required this.macAddress, + }); + + String? networkId; + String? endpointId; + String? gateway; + String? ipAddress; + int? ipPrefixLen; + String? iPv6Gateway; + String? globalIPv6Address; + int? globalIPv6PrefixLen; + String? macAddress; + + factory Bridge.fromJson(Map json) => Bridge( + networkId: json["NetworkID"], + endpointId: json["EndpointID"], + gateway: json["Gateway"], + ipAddress: json["IPAddress"], + ipPrefixLen: json["IPPrefixLen"], + iPv6Gateway: json["IPv6Gateway"], + globalIPv6Address: json["GlobalIPv6Address"], + globalIPv6PrefixLen: json["GlobalIPv6PrefixLen"], + macAddress: json["MacAddress"], + ); + + Map toJson() => { + "NetworkID": networkId, + "EndpointID": endpointId, + "Gateway": gateway, + "IPAddress": ipAddress, + "IPPrefixLen": ipPrefixLen, + "IPv6Gateway": iPv6Gateway, + "GlobalIPv6Address": globalIPv6Address, + "GlobalIPv6PrefixLen": globalIPv6PrefixLen, + "MacAddress": macAddress, + }; +} + +class DockerState { + DockerState({ + required this.error, + required this.exitCode, + required this.finishedAt, + required this.health, + required this.oomKilled, + required this.dead, + required this.paused, + required this.pid, + required this.restarting, + required this.running, + required this.startedAt, + required this.status, + }); + + String? error; + int? exitCode; + DateTime? finishedAt; + Health? health; + bool? oomKilled; + bool? dead; + bool? paused; + int? pid; + bool? restarting; + bool? running; + DateTime? startedAt; + String? status; + + factory DockerState.fromJson(Map json) => DockerState( + error: json["Error"], + exitCode: json["ExitCode"], + finishedAt: DateTime.parse(json["FinishedAt"]), + health: json["Health"] != null ? Health.fromJson(json["Health"]) : null, + oomKilled: json["OOMKilled"], + dead: json["Dead"], + paused: json["Paused"], + pid: json["Pid"], + restarting: json["Restarting"], + running: json["Running"], + startedAt: DateTime.parse(json["StartedAt"]), + status: json["Status"], + ); + + Map toJson() => { + "Error": error, + "ExitCode": exitCode, + "FinishedAt": finishedAt?.toIso8601String(), + "Health": health?.toJson(), + "OOMKilled": oomKilled, + "Dead": dead, + "Paused": paused, + "Pid": pid, + "Restarting": restarting, + "Running": running, + "StartedAt": startedAt?.toIso8601String(), + "Status": status, + }; +} + +class Health { + Health({ + required this.status, + required this.failingStreak, + required this.log, + }); + + String? status; + int? failingStreak; + List? log; + + factory Health.fromJson(Map json) => Health( + status: json["Status"], + failingStreak: json["FailingStreak"], + log: List.from(json["Log"].map((x) => Log.fromJson(x))), + ); + + Map toJson() => { + "Status": status, + "FailingStreak": failingStreak, + "Log": List.from(log?.map((x) => x.toJson()) ?? []), + }; +} + +class Log { + Log({ + required this.start, + required this.end, + required this.exitCode, + required this.output, + }); + + DateTime start; + DateTime end; + int exitCode; + String output; + + factory Log.fromJson(Map json) => Log( + start: DateTime.parse(json["Start"]), + end: DateTime.parse(json["End"]), + exitCode: json["ExitCode"], + output: json["Output"], + ); + + Map toJson() => { + "Start": start.toIso8601String(), + "End": end.toIso8601String(), + "ExitCode": exitCode, + "Output": output, + }; +} diff --git a/lib/models/docker/docker_container.dart b/lib/models/docker/docker_container.dart new file mode 100644 index 0000000..21f9128 --- /dev/null +++ b/lib/models/docker/docker_container.dart @@ -0,0 +1,290 @@ +// To parse this JSON data, do +// +// final token = tokenFromJson(jsonString); + +import 'dart:convert'; + +List dockerContainerFromJson(String str) => + List.from( + json.decode(str).map((x) => DockerContainer.fromJson(x))); + +String dockerContainerToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); + +/// Smaller representation of a container than inspecting a single container +class DockerContainer { + DockerContainer({ + required this.id, + required this.names, + required this.image, + required this.imageId, + required this.command, + required this.created, + required this.state, + required this.status, + required this.ports, + required this.labels, + required this.sizeRw, + required this.sizeRootFs, + required this.hostConfig, + required this.networkSettings, + required this.mounts, + }); + + String id; + List? names; + String? image; + String? imageId; + String? command; + int? created; + String? state; + String? status; + List? ports; + Labels? labels; + int? sizeRw; + int? sizeRootFs; + HostConfig? hostConfig; + NetworkSettings? networkSettings; + List? mounts; + + factory DockerContainer.fromJson(Map json) => + DockerContainer( + id: json["Id"], + names: List.from(json["Names"]?.map((x) => x) ?? []), + image: json["Image"], + imageId: json["ImageID"], + command: json["Command"], + created: json["Created"], + state: json["State"], + status: json["Status"], + ports: + List.from(json["Ports"]?.map((x) => Port.fromJson(x)) ?? []), + labels: Labels.fromJson(json["Labels"] ?? {}), + sizeRw: json["SizeRw"], + sizeRootFs: json["SizeRootFs"], + hostConfig: HostConfig.fromJson(json["HostConfig"] ?? {}), + networkSettings: NetworkSettings.fromJson(json["NetworkSettings"]), + mounts: List.from( + json["Mounts"]?.map((x) => Mount.fromJson(x)) ?? []), + ); + + Map toJson() => { + "Id": id, + "Names": List.from(names?.map((x) => x) ?? []), + "Image": image, + "ImageID": imageId, + "Command": command, + "Created": created, + "State": state, + "Status": status, + "Ports": List.from(ports?.map((x) => x.toJson()) ?? []), + "Labels": labels?.toJson(), + "SizeRw": sizeRw, + "SizeRootFs": sizeRootFs, + "HostConfig": hostConfig?.toJson(), + "NetworkSettings": networkSettings?.toJson(), + "Mounts": List.from(mounts?.map((x) => x.toJson()) ?? []), + }; + + void updateContainer(DockerContainer container) { + id = container.id; + names = container.names!.isEmpty ? names : container.names; + image = container.image ?? image; + imageId = container.imageId ?? imageId; + command = container.command ?? command; + created = container.created ?? created; + state = container.state ?? state; + status = container.status ?? status; + ports = container.ports!.isEmpty ? ports : container.ports; + labels = container.labels ?? labels; + sizeRw = container.sizeRw ?? sizeRw; + sizeRootFs = container.sizeRootFs ?? sizeRootFs; + hostConfig = container.hostConfig ?? hostConfig; + networkSettings = container.networkSettings ?? networkSettings; + mounts = container.mounts!.isEmpty ? mounts : container.mounts; + } +} + +class Bridge { + Bridge({ + required this.networkId, + required this.endpointId, + required this.gateway, + required this.ipAddress, + required this.ipPrefixLen, + required this.iPv6Gateway, + required this.globalIPv6Address, + required this.globalIPv6PrefixLen, + required this.macAddress, + }); + + String? networkId; + String? endpointId; + String? gateway; + String? ipAddress; + int? ipPrefixLen; + String? iPv6Gateway; + String? globalIPv6Address; + int? globalIPv6PrefixLen; + String? macAddress; + + factory Bridge.fromJson(Map json) => Bridge( + networkId: json["NetworkID"], + endpointId: json["EndpointID"], + gateway: json["Gateway"], + ipAddress: json["IPAddress"], + ipPrefixLen: json["IPPrefixLen"], + iPv6Gateway: json["IPv6Gateway"], + globalIPv6Address: json["GlobalIPv6Address"], + globalIPv6PrefixLen: json["GlobalIPv6PrefixLen"], + macAddress: json["MacAddress"], + ); + + Map toJson() => { + "NetworkID": networkId, + "EndpointID": endpointId, + "Gateway": gateway, + "IPAddress": ipAddress, + "IPPrefixLen": ipPrefixLen, + "IPv6Gateway": iPv6Gateway, + "GlobalIPv6Address": globalIPv6Address, + "GlobalIPv6PrefixLen": globalIPv6PrefixLen, + "MacAddress": macAddress, + }; +} + +class Port { + Port({ + required this.privatePort, + required this.publicPort, + required this.type, + }); + + int? privatePort; + int? publicPort; + String? type; + + factory Port.fromJson(Map json) => Port( + privatePort: json["PrivatePort"], + publicPort: json["PublicPort"], + type: json["Type"], + ); + + Map toJson() => { + "PrivatePort": privatePort, + "PublicPort": publicPort, + "Type": type, + }; +} + +class HostConfig { + HostConfig({ + required this.networkMode, + }); + + String networkMode; + + factory HostConfig.fromJson(Map json) => HostConfig( + networkMode: json["NetworkMode"], + ); + + Map toJson() => { + "NetworkMode": networkMode, + }; +} + +class Labels { + /// This class maps all the labels that are used in the Docker image. + + Labels({ + required this.labels, + }); + + Map labels; + + factory Labels.fromJson(Map json) { + Map tempLabels = {}; + tempLabels.addAll(json); + + return Labels( + labels: tempLabels, + ); + } + + Map toJson() => { + 'Labels': labels, + }; +} + +class Mount { + Mount({ + required this.name, + required this.source, + required this.destination, + required this.driver, + required this.mode, + required this.rw, + required this.propagation, + }); + + String? name; + String? source; + String? destination; + String? driver; + String? mode; + bool? rw; + String? propagation; + + factory Mount.fromJson(Map json) => Mount( + name: json["Name"], + source: json["Source"], + destination: json["Destination"], + driver: json["Driver"], + mode: json["Mode"], + rw: json["RW"], + propagation: json["Propagation"], + ); + + Map toJson() => { + "Name": name, + "Source": source, + "Destination": destination, + "Driver": driver, + "Mode": mode, + "RW": rw, + "Propagation": propagation, + }; +} + +class NetworkSettings { + NetworkSettings({ + required this.networks, + }); + + Networks networks; + + factory NetworkSettings.fromJson(Map json) => + NetworkSettings( + networks: Networks.fromJson(json["Networks"]), + ); + + Map toJson() => { + "Networks": networks.toJson(), + }; +} + +class Networks { + Networks({ + required this.bridge, + }); + + Bridge? bridge; + + factory Networks.fromJson(Map json) => Networks( + bridge: Bridge.fromJson(json["bridge"] ?? {}), + ); + + Map toJson() => { + "bridge": bridge?.toJson(), + }; +} diff --git a/lib/models/hive/token.g.dart b/lib/models/hive/token.g.dart new file mode 100644 index 0000000..ff6128d --- /dev/null +++ b/lib/models/hive/token.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../portainer/token.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class TokenAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + Token read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Token( + jwt: fields[0] as String, + ); + } + + @override + void write(BinaryWriter writer, Token obj) { + writer + ..writeByte(1) + ..writeByte(0) + ..write(obj.jwt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/hive/user.g.dart b/lib/models/hive/user.g.dart new file mode 100644 index 0000000..a2e2f2f --- /dev/null +++ b/lib/models/hive/user.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../portainer/user.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + User read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return User( + username: fields[0] as String, + password: fields[1] as String, + hostUrl: fields[2] as String, + token: fields[3] as Token?, + ); + } + + @override + void write(BinaryWriter writer, User obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.username) + ..writeByte(1) + ..write(obj.password) + ..writeByte(2) + ..write(obj.hostUrl) + ..writeByte(3) + ..write(obj.token); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/portainer/endpoint.dart b/lib/models/portainer/endpoint.dart new file mode 100644 index 0000000..2350b40 --- /dev/null +++ b/lib/models/portainer/endpoint.dart @@ -0,0 +1,617 @@ +// To parse this JSON data, do +// +// final endpoint = endpointFromJson(jsonString); + +import 'dart:convert'; + +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/services/remote.dart'; + +Endpoint endpointFromJson(String str) => Endpoint.fromJson(json.decode(str)); + +String endpointToJson(Endpoint data) => json.encode(data.toJson()); + +class Endpoint { + Endpoint({ + this.authorizedTeams, + this.authorizedUsers, + this.azureCredentials, + this.composeSyntaxMaxVersion, + this.edgeCheckinInterval, + this.edgeId, + this.edgeKey, + this.extensions, + this.groupId, + this.id, + this.kubernetes, + this.name, + this.publicUrl, + this.snapshots, + this.status, + this.tls, + this.tlscaCert, + this.tlsCert, + this.tlsConfig, + this.tlsKey, + this.tagIds, + this.tags, + this.teamAccessPolicies, + this.type, + this.url, + this.userAccessPolicies, + this.lastCheckInDate, + this.securitySettings, + }); + + List? authorizedTeams; + List? authorizedUsers; + AzureCredentials? azureCredentials; + String? composeSyntaxMaxVersion; + int? edgeCheckinInterval; + String? edgeId; + String? edgeKey; + List? extensions; + int? groupId; + int? id; + Kubernetes? kubernetes; + String? name; + String? publicUrl; + List? snapshots; + int? status; + bool? tls; + String? tlscaCert; + String? tlsCert; + TlsConfig? tlsConfig; + String? tlsKey; + List? tagIds; + List? tags; + AccessPolicies? teamAccessPolicies; + int? type; + String? url; + AccessPolicies? userAccessPolicies; + int? lastCheckInDate; + SecuritySettings? securitySettings; + + factory Endpoint.fromJson(Map json) => Endpoint( + authorizedTeams: + List.from(json["AuthorizedTeams"]?.map((x) => x) ?? []), + authorizedUsers: + List.from(json["AuthorizedUsers"]?.map((x) => x) ?? []), + azureCredentials: + AzureCredentials.fromJson(json["AzureCredentials"] ?? {}), + composeSyntaxMaxVersion: json["ComposeSyntaxMaxVersion"], + edgeCheckinInterval: json["EdgeCheckinInterval"], + edgeId: json["EdgeID"], + edgeKey: json["EdgeKey"], + extensions: List.from( + json["Extensions"]?.map((x) => Extension.fromJson(x)) ?? []), + groupId: json["GroupId"], + id: json["Id"], + kubernetes: Kubernetes.fromJson(json["Kubernetes"] ?? {}), + name: json["Name"], + publicUrl: json["PublicURL"], + snapshots: List.from( + json["Snapshots"]?.map((x) => EndpointSnapshot.fromJson(x)) ?? []), + status: json["Status"], + tls: json["TLS"], + tlscaCert: json["TLSCACert"], + tlsCert: json["TLSCert"], + tlsConfig: TlsConfig.fromJson(json["TLSConfig"] ?? {}), + tlsKey: json["TLSKey"], + tagIds: List.from(json["TagIds"]?.map((x) => x) ?? []), + tags: List.from(json["Tags"]?.map((x) => x) ?? []), + teamAccessPolicies: + AccessPolicies.fromJson(json["TeamAccessPolicies"] ?? {}), + type: json["Type"], + url: json["URL"], + userAccessPolicies: + AccessPolicies.fromJson(json["UserAccessPolicies"] ?? {}), + lastCheckInDate: json["lastCheckInDate"], + securitySettings: + SecuritySettings.fromJson(json["securitySettings"] ?? {}), + ); + + Map toJson() => { + "AuthorizedTeams": + List.from(authorizedTeams?.map((x) => x) ?? []), + "AuthorizedUsers": + List.from(authorizedUsers?.map((x) => x) ?? []), + "AzureCredentials": azureCredentials?.toJson(), + "ComposeSyntaxMaxVersion": composeSyntaxMaxVersion, + "EdgeCheckinInterval": edgeCheckinInterval, + "EdgeID": edgeId, + "EdgeKey": edgeKey, + "Extensions": + List.from(extensions?.map((x) => x.toJson()) ?? []), + "GroupId": groupId, + "Id": id, + "Kubernetes": kubernetes?.toJson(), + "Name": name, + "PublicURL": publicUrl, + "Snapshots": List.from(snapshots!.map((x) => x.toJson())), + "Status": status, + "TLS": tls, + "TLSCACert": tlscaCert, + "TLSCert": tlsCert, + "TLSConfig": tlsConfig?.toJson(), + "TLSKey": tlsKey, + "TagIds": List.from(tagIds?.map((x) => x) ?? []), + "Tags": List.from(tags?.map((x) => x) ?? []), + "TeamAccessPolicies": teamAccessPolicies?.toJson(), + "Type": type, + "URL": url, + "UserAccessPolicies": userAccessPolicies?.toJson(), + "lastCheckInDate": lastCheckInDate, + "securitySettings": securitySettings?.toJson(), + }; + + /// Refresh the endpoint's status. + Future refreshEndpoint(User user) async { + Endpoint newEndpint = (await RemoteService().getEndpoints(user)).firstWhere( + (x) => x.id == id, + orElse: () => throw Exception("Endpoint not found")); + authorizedTeams = newEndpint.authorizedTeams; + authorizedUsers = newEndpint.authorizedUsers; + azureCredentials = newEndpint.azureCredentials; + composeSyntaxMaxVersion = newEndpint.composeSyntaxMaxVersion; + edgeCheckinInterval = newEndpint.edgeCheckinInterval; + edgeId = newEndpint.edgeId; + edgeKey = newEndpint.edgeKey; + extensions = newEndpint.extensions; + groupId = newEndpint.groupId; + id = newEndpint.id; + kubernetes = newEndpint.kubernetes; + name = newEndpint.name; + publicUrl = newEndpint.publicUrl; + snapshots = newEndpint.snapshots; + status = newEndpint.status; + tls = newEndpint.tls; + tlscaCert = newEndpint.tlscaCert; + tlsCert = newEndpint.tlsCert; + tlsConfig = newEndpint.tlsConfig; + tlsKey = newEndpint.tlsKey; + tagIds = newEndpint.tagIds; + tags = newEndpint.tags; + teamAccessPolicies = newEndpint.teamAccessPolicies; + type = newEndpint.type; + url = newEndpint.url; + userAccessPolicies = newEndpint.userAccessPolicies; + lastCheckInDate = newEndpint.lastCheckInDate; + securitySettings = newEndpint.securitySettings; + } +} + +class AzureCredentials { + AzureCredentials({ + this.applicationId, + this.authenticationKey, + this.tenantId, + }); + + String? applicationId; + String? authenticationKey; + String? tenantId; + + factory AzureCredentials.fromJson(Map json) => + AzureCredentials( + applicationId: json["ApplicationID"], + authenticationKey: json["AuthenticationKey"], + tenantId: json["TenantID"], + ); + + Map toJson() => { + "ApplicationID": applicationId, + "AuthenticationKey": authenticationKey, + "TenantID": tenantId, + }; +} + +class Extension { + Extension({ + this.type, + this.url, + }); + + int? type; + String? url; + + factory Extension.fromJson(Map json) => Extension( + type: json["Type"], + url: json["URL"], + ); + + Map toJson() => { + "Type": type, + "URL": url, + }; +} + +class Kubernetes { + Kubernetes({ + this.configuration, + this.snapshots, + }); + + Configuration? configuration; + List? snapshots; + + factory Kubernetes.fromJson(Map json) => Kubernetes( + configuration: Configuration.fromJson(json["Configuration"]), + snapshots: List.from( + json["Snapshots"]?.map((x) => KubernetesSnapshot.fromJson(x)) ?? + []), + ); + + Map toJson() => { + "Configuration": configuration?.toJson(), + "Snapshots": + List.from(snapshots?.map((x) => x.toJson()) ?? []), + }; +} + +class Configuration { + Configuration({ + this.ingressClasses, + this.restrictDefaultNamespace, + this.storageClasses, + this.useLoadBalancer, + this.useServerMetrics, + }); + + List? ingressClasses; + bool? restrictDefaultNamespace; + List? storageClasses; + bool? useLoadBalancer; + bool? useServerMetrics; + + factory Configuration.fromJson(Map json) => Configuration( + ingressClasses: List.from( + json["IngressClasses"]?.map((x) => IngressClass.fromJson(x)) ?? []), + restrictDefaultNamespace: json["RestrictDefaultNamespace"], + storageClasses: List.from( + json["StorageClasses"]?.map((x) => StorageClass.fromJson(x)) ?? []), + useLoadBalancer: json["UseLoadBalancer"], + useServerMetrics: json["UseServerMetrics"], + ); + + Map toJson() => { + "IngressClasses": + List.from(ingressClasses?.map((x) => x.toJson()) ?? []), + "RestrictDefaultNamespace": restrictDefaultNamespace, + "StorageClasses": + List.from(storageClasses?.map((x) => x.toJson()) ?? []), + "UseLoadBalancer": useLoadBalancer, + "UseServerMetrics": useServerMetrics, + }; +} + +class IngressClass { + IngressClass({ + this.name, + this.type, + }); + + String? name; + String? type; + + factory IngressClass.fromJson(Map json) => IngressClass( + name: json["Name"], + type: json["Type"], + ); + + Map toJson() => { + "Name": name, + "Type": type, + }; +} + +class StorageClass { + StorageClass({ + this.accessModes, + this.allowVolumeExpansion, + this.name, + this.provisioner, + }); + + List? accessModes; + bool? allowVolumeExpansion; + String? name; + String? provisioner; + + factory StorageClass.fromJson(Map json) => StorageClass( + accessModes: List.from(json["AccessModes"].map((x) => x)), + allowVolumeExpansion: json["AllowVolumeExpansion"], + name: json["Name"], + provisioner: json["Provisioner"], + ); + + Map toJson() => { + "AccessModes": List.from(accessModes?.map((x) => x) ?? []), + "AllowVolumeExpansion": allowVolumeExpansion, + "Name": name, + "Provisioner": provisioner, + }; +} + +class KubernetesSnapshot { + KubernetesSnapshot({ + this.kubernetesVersion, + this.nodeCount, + this.time, + this.totalCpu, + this.totalMemory, + }); + + String? kubernetesVersion; + int? nodeCount; + int? time; + int? totalCpu; + int? totalMemory; + + factory KubernetesSnapshot.fromJson(Map json) => + KubernetesSnapshot( + kubernetesVersion: json["KubernetesVersion"], + nodeCount: json["NodeCount"], + time: json["Time"], + totalCpu: json["TotalCPU"], + totalMemory: json["TotalMemory"], + ); + + Map toJson() => { + "KubernetesVersion": kubernetesVersion, + "NodeCount": nodeCount, + "Time": time, + "TotalCPU": totalCpu, + "TotalMemory": totalMemory, + }; +} + +class SecuritySettings { + SecuritySettings({ + this.allowBindMountsForRegularUsers, + this.allowContainerCapabilitiesForRegularUsers, + this.allowDeviceMappingForRegularUsers, + this.allowHostNamespaceForRegularUsers, + this.allowPrivilegedModeForRegularUsers, + this.allowStackManagementForRegularUsers, + this.allowSysctlSettingForRegularUsers, + this.allowVolumeBrowserForRegularUsers, + this.enableHostManagementFeatures, + }); + + bool? allowBindMountsForRegularUsers; + bool? allowContainerCapabilitiesForRegularUsers; + bool? allowDeviceMappingForRegularUsers; + bool? allowHostNamespaceForRegularUsers; + bool? allowPrivilegedModeForRegularUsers; + bool? allowStackManagementForRegularUsers; + bool? allowSysctlSettingForRegularUsers; + bool? allowVolumeBrowserForRegularUsers; + bool? enableHostManagementFeatures; + + factory SecuritySettings.fromJson(Map json) => + SecuritySettings( + allowBindMountsForRegularUsers: json["allowBindMountsForRegularUsers"], + allowContainerCapabilitiesForRegularUsers: + json["allowContainerCapabilitiesForRegularUsers"], + allowDeviceMappingForRegularUsers: + json["allowDeviceMappingForRegularUsers"], + allowHostNamespaceForRegularUsers: + json["allowHostNamespaceForRegularUsers"], + allowPrivilegedModeForRegularUsers: + json["allowPrivilegedModeForRegularUsers"], + allowStackManagementForRegularUsers: + json["allowStackManagementForRegularUsers"], + allowSysctlSettingForRegularUsers: + json["allowSysctlSettingForRegularUsers"], + allowVolumeBrowserForRegularUsers: + json["allowVolumeBrowserForRegularUsers"], + enableHostManagementFeatures: json["enableHostManagementFeatures"], + ); + + Map toJson() => { + "allowBindMountsForRegularUsers": allowBindMountsForRegularUsers, + "allowContainerCapabilitiesForRegularUsers": + allowContainerCapabilitiesForRegularUsers, + "allowDeviceMappingForRegularUsers": allowDeviceMappingForRegularUsers, + "allowHostNamespaceForRegularUsers": allowHostNamespaceForRegularUsers, + "allowPrivilegedModeForRegularUsers": + allowPrivilegedModeForRegularUsers, + "allowStackManagementForRegularUsers": + allowStackManagementForRegularUsers, + "allowSysctlSettingForRegularUsers": allowSysctlSettingForRegularUsers, + "allowVolumeBrowserForRegularUsers": allowVolumeBrowserForRegularUsers, + "enableHostManagementFeatures": enableHostManagementFeatures, + }; +} + +class EndpointSnapshot { + EndpointSnapshot({ + this.dockerSnapshotRaw, + this.dockerVersion, + this.healthyContainerCount, + this.imageCount, + this.nodeCount, + this.runningContainerCount, + this.serviceCount, + this.stackCount, + this.stoppedContainerCount, + this.swarm, + this.time, + this.totalCpu, + this.totalMemory, + this.unhealthyContainerCount, + this.volumeCount, + }); + + DockerSnapshotRaw? dockerSnapshotRaw; + String? dockerVersion; + int? healthyContainerCount; + int? imageCount; + int? nodeCount; + int? runningContainerCount; + int? serviceCount; + int? stackCount; + int? stoppedContainerCount; + bool? swarm; + int? time; + int? totalCpu; + int? totalMemory; + int? unhealthyContainerCount; + int? volumeCount; + + factory EndpointSnapshot.fromJson(Map json) => + EndpointSnapshot( + dockerSnapshotRaw: + DockerSnapshotRaw.fromJson(json["DockerSnapshotRaw"]), + dockerVersion: json["DockerVersion"], + healthyContainerCount: json["HealthyContainerCount"], + imageCount: json["ImageCount"], + nodeCount: json["NodeCount"], + runningContainerCount: json["RunningContainerCount"], + serviceCount: json["ServiceCount"], + stackCount: json["StackCount"], + stoppedContainerCount: json["StoppedContainerCount"], + swarm: json["Swarm"], + time: json["Time"], + totalCpu: json["TotalCPU"], + totalMemory: json["TotalMemory"], + unhealthyContainerCount: json["UnhealthyContainerCount"], + volumeCount: json["VolumeCount"], + ); + + Map toJson() => { + "DockerSnapshotRaw": dockerSnapshotRaw?.toJson(), + "DockerVersion": dockerVersion, + "HealthyContainerCount": healthyContainerCount, + "ImageCount": imageCount, + "NodeCount": nodeCount, + "RunningContainerCount": runningContainerCount, + "ServiceCount": serviceCount, + "StackCount": stackCount, + "StoppedContainerCount": stoppedContainerCount, + "Swarm": swarm, + "Time": time, + "TotalCPU": totalCpu, + "TotalMemory": totalMemory, + "UnhealthyContainerCount": unhealthyContainerCount, + "VolumeCount": volumeCount, + }; +} + +class DockerSnapshotRaw { + DockerSnapshotRaw({ + this.containers, + this.images, + this.info, + this.networks, + this.version, + this.volumes, + }); + + Containers? containers; + Containers? images; + Containers? info; + Containers? networks; + Containers? version; + Containers? volumes; + + factory DockerSnapshotRaw.fromJson(Map json) => + DockerSnapshotRaw( + containers: Containers.fromJson(json["Containers"] ?? {}), + images: Containers.fromJson(json["Images"] ?? {}), + info: Containers.fromJson(json["Info"] ?? {}), + networks: Containers.fromJson(json["Networks"] ?? {}), + version: Containers.fromJson(json["Version"] ?? {}), + volumes: Containers.fromJson(json["Volumes"] ?? {}), + ); + + Map toJson() => { + "Containers": containers?.toJson(), + "Images": images?.toJson(), + "Info": info?.toJson(), + "Networks": networks?.toJson(), + "Version": version?.toJson(), + "Volumes": volumes?.toJson(), + }; +} + +class Containers { + Containers(); + + factory Containers.fromJson(Map json) => Containers(); + + Map toJson() => {}; +} + +class AccessPolicies { + AccessPolicies({ + this.additionalProp1, + this.additionalProp2, + this.additionalProp3, + }); + + AdditionalProp? additionalProp1; + AdditionalProp? additionalProp2; + AdditionalProp? additionalProp3; + + factory AccessPolicies.fromJson(Map json) => AccessPolicies( + additionalProp1: AdditionalProp.fromJson(json["additionalProp1"] ?? {}), + additionalProp2: AdditionalProp.fromJson(json["additionalProp2"] ?? {}), + additionalProp3: AdditionalProp.fromJson(json["additionalProp3"] ?? {}), + ); + + Map toJson() => { + "additionalProp1": additionalProp1?.toJson(), + "additionalProp2": additionalProp2?.toJson(), + "additionalProp3": additionalProp3?.toJson(), + }; +} + +class AdditionalProp { + AdditionalProp({ + this.roleId, + }); + + int? roleId; + + factory AdditionalProp.fromJson(Map json) => AdditionalProp( + roleId: json["RoleId"], + ); + + Map toJson() => { + "RoleId": roleId, + }; +} + +class TlsConfig { + TlsConfig({ + this.tls, + this.tlscaCert, + this.tlsCert, + this.tlsKey, + this.tlsSkipVerify, + }); + + bool? tls; + String? tlscaCert; + String? tlsCert; + String? tlsKey; + bool? tlsSkipVerify; + + factory TlsConfig.fromJson(Map json) => TlsConfig( + tls: json["TLS"], + tlscaCert: json["TLSCACert"], + tlsCert: json["TLSCert"], + tlsKey: json["TLSKey"], + tlsSkipVerify: json["TLSSkipVerify"], + ); + + Map toJson() => { + "TLS": tls, + "TLSCACert": tlscaCert, + "TLSCert": tlsCert, + "TLSKey": tlsKey, + "TLSSkipVerify": tlsSkipVerify, + }; +} diff --git a/lib/models/portainer/token.dart b/lib/models/portainer/token.dart new file mode 100644 index 0000000..2817987 --- /dev/null +++ b/lib/models/portainer/token.dart @@ -0,0 +1,36 @@ +// To parse this JSON data, do +// +// final token = tokenFromJson(jsonString); +import 'dart:convert'; + +import 'package:hive/hive.dart'; + +part '../hive/token.g.dart'; + +Token tokenFromJson(String str) => Token.fromJson(json.decode(str)); + +String tokenToJson(Token data) => json.encode(data.toJson()); + +@HiveType(typeId: 1, adapterName: 'TokenAdapter') +class Token { + Token({ + required this.jwt, + }); + + @HiveField(0) + String jwt; + + factory Token.fromJson(Map json) => Token( + jwt: json["jwt"], + ); + + Map toJson() => { + "jwt": jwt, + }; + + String getBearerToken() { + return 'Bearer $jwt'; + } + + bool get hasJwt => jwt.isNotEmpty; +} diff --git a/lib/models/portainer/user.dart b/lib/models/portainer/user.dart new file mode 100644 index 0000000..b9b7ed4 --- /dev/null +++ b/lib/models/portainer/user.dart @@ -0,0 +1,124 @@ +import 'package:flutter/cupertino.dart'; +import 'package:hive/hive.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:provider/provider.dart'; +import 'package:toast/toast.dart'; +import '../../services/remote.dart'; +import 'token.dart'; + +part '../hive/user.g.dart'; + +/// [User] model for Portainer. +/// +/// This model is used to store the username, password and host URL of the user. +/// [Token] is also stored in this model, but can be null. +/// +/// The token is used to access the Portainer API. +@HiveType(typeId: 0, adapterName: 'UserAdapter') +class User extends ChangeNotifier { + /// [username] of the user. + @HiveField(0) + String username; + + /// [password] of the user. + @HiveField(1) + String password; + + /// [hostUrl] of the portainer API + @HiveField(2) + String hostUrl; + + /// [token] for the API calls + @HiveField(3) + late Token? token; + + User( + {required this.username, + required this.password, + required this.hostUrl, + this.token}); + + /// Tries to auth [User] + /// + /// If [Token] is returned, it is stored in [User] + /// If [Token] is null, null is returned + Future authPortainer() async { + Token? newToken = await RemoteService().authPortainer( + username, + password, + hostUrl, + ); + + if (newToken == null) { + return null; + } + + if (newToken.jwt.isNotEmpty) { + print('Setting new token.'); + token = newToken; + notifyListeners(); + } + + return newToken; + } + + /// Checks if [Token] is valid + Future isTokenValid(Token token) async { + return await RemoteService().isTokenValid(this); + } + + /// Logs out the [User]. + /// It will remove [User] from Hive storage. + /// It will also remove [Token] from [User]. + /// [BuildContext] is required to get the [StorageManager] from the [Provider] + Future logOutUser(BuildContext context) async { + if (await RemoteService().logoutPortainer(this)) { + var storage = Provider.of(context, listen: false); + await storage.clearUser(); + + Future.delayed(const Duration(milliseconds: 100), () { + username = ''; + password = ''; + hostUrl = ''; + resetToken(); + }); + } else { + Toast.show('Logout failed.'); + } + } + + void setToken(Token newToken) { + token = newToken; + notifyListeners(); + } + + void resetToken() { + token = null; + notifyListeners(); + } + + void setNewUser(User user) { + username = user.username; + password = user.password; + + if (user.hostUrl.endsWith('/')) { + hostUrl = user.hostUrl.substring(0, user.hostUrl.length - 1); + } else { + hostUrl = user.hostUrl; + } + + token = user.token; + notifyListeners(); + } + + /// Operator == for [User] + /// Two [User] are equal if their [username] and [hostUrl] and [token] are equal. + @override + // ignore: hash_and_equals + bool operator ==(Object other) => + identical(this, other) || + other is User && + runtimeType == other.runtimeType && + username == other.username && + hostUrl == other.hostUrl; +} diff --git a/lib/pages/auth/authpage.dart b/lib/pages/auth/authpage.dart new file mode 100644 index 0000000..025ac79 --- /dev/null +++ b/lib/pages/auth/authpage.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:portarius/components/buttons/big_blue_button.dart'; +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/style.dart'; +import 'package:provider/provider.dart'; +import 'package:toast/toast.dart'; + +import '../../models/portainer/token.dart'; + +/// [AuthPage] for Portarius. +/// This page is used to authenticate with Portainer. +/// It will display a form to enter the host url, username and password. +/// Once the user has entered the credentials, it will send a request to Portainer +/// to authenticate. If the credentials are correct, it will store the token in +/// the local storage. +/// The token will be used to access the Portainer API. +class AuthPage extends StatefulWidget { + const AuthPage({Key? key}) : super(key: key); + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + final TextEditingController _hostUrlController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + + final _formKey = GlobalKey(); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + + // Open the local storage. + _loadDataFromStorage(); + } + + @override + void dispose() { + super.dispose(); + _hostUrlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + } + + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + StorageManager storage = Provider.of(context, listen: true); + StyleManager style = Provider.of(context, listen: true); + Size size = MediaQuery.of(context).size; + + if (user.token != null && mounted) { + Future.delayed(const Duration(milliseconds: 150), () { + Navigator.pushReplacementNamed(context, '/'); + }); + } + + return Scaffold( + body: SingleChildScrollView( + child: SizedBox( + height: size.height, + width: size.width, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + flex: 4, + child: Container( + margin: EdgeInsets.only(top: size.height * 0.05), + child: + Image.asset('assets/icons/icon.png', fit: BoxFit.fill)), + ), + Flexible( + flex: 5, + child: Center( + child: SizedBox( + width: size.width * 0.85 > 500 ? 500 : size.width * 0.85, + child: Card( + elevation: 1, + child: Padding( + padding: const EdgeInsets.all(15.0), + child: Form( + autovalidateMode: AutovalidateMode.disabled, + key: _formKey, + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Authentication', + style: Theme.of(context) + .textTheme + .headline6 + ?.copyWith(fontWeight: FontWeight.w500), + ), + SizedBox( + height: 15, + width: size.width, + ), + TextFormField( + controller: _hostUrlController, + scrollPadding: + EdgeInsets.all(size.height * .15), + textInputAction: TextInputAction.next, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Host URL', + hintText: 'https://portainer.example.com', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a host URL'; + } + return null; + }, + ), + SizedBox( + height: 10, + width: size.width, + ), + TextFormField( + controller: _usernameController, + scrollPadding: EdgeInsets.all(size.height * .1), + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'admin', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username'; + } + return null; + }, + ), + SizedBox( + height: 10, + width: size.width, + ), + TextFormField( + controller: _passwordController, + scrollPadding: + EdgeInsets.all(size.height * .12), + obscureText: true, + textInputAction: TextInputAction.done, + onEditingComplete: () => + _authenticateButton(user, storage), + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + return null; + }, + ), + SizedBox( + height: 15, + width: size.width, + ), + if (_isLoading) ...[ + const CircularProgressIndicator(), + ] else ...[ + BigBlueButton( + formKey: _formKey, + onClick: () => + _authenticateButton(user, storage), + buttonTitle: 'Log In', + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + Flexible( + flex: 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + IconButton( + icon: const Icon(Icons.group), + onPressed: () { + Navigator.pushNamed(context, '/users', arguments: true); + }, + ), + IconButton( + icon: Theme.of(context).brightness == Brightness.dark + ? const Icon(Icons.light_mode) + : const Icon(Icons.dark_mode), + onPressed: () { + style.setTheme( + Theme.of(context).brightness == Brightness.dark + ? ThemeMode.light + : ThemeMode.dark); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + _loadDataFromStorage() async { + setState(() { + _isLoading = true; + }); + + // Load the data from the local storage. + var box = await Hive.openBox('portainer'); + + User? userData = box.get('user'); + + setState(() { + _hostUrlController.text = userData?.hostUrl ?? ''; + _usernameController.text = userData?.username ?? ''; + _passwordController.text = userData?.password ?? ''; + + _isLoading = false; + }); + } + + _authenticateButton(User user, StorageManager storage) async { + setState(() { + _isLoading = true; + }); + + user.username = _usernameController.text; + user.password = _passwordController.text; + user.hostUrl = _hostUrlController.text; + + Token? token = await user.authPortainer(); + + if (token != null) { + await storage.addUserToList(user); + await storage.saveUser(user); + user.setToken(token); + } else { + _showToast('Authentication failed.'); + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + _showToast(String message) { + ToastContext().init(context); + Toast.show(message, duration: Toast.lengthLong); + } +} diff --git a/lib/pages/container/container_details.dart b/lib/pages/container/container_details.dart new file mode 100644 index 0000000..399479e --- /dev/null +++ b/lib/pages/container/container_details.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:portarius/components/appbar/appbar.dart'; +import 'package:portarius/components/cards/status_card.dart'; +import 'package:portarius/models/docker/detailed_container.dart'; +import 'package:portarius/models/docker/docker_container.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:provider/provider.dart'; +import 'package:toast/toast.dart'; + +import '../../models/portainer/user.dart'; + +class ContainerDetailsPage extends StatefulWidget { + const ContainerDetailsPage({Key? key}) : super(key: key); + + @override + State createState() => _ContainerDetailsPageState(); +} + +class _ContainerDetailsPageState extends State { + final ScrollController _scrollController = ScrollController(); + final ScrollController _logScrollController = ScrollController(); + bool _refreshing = false; + DetailedDockerContainer? detailedContainer; + bool _onLoad = true; + bool _showLogs = false; + bool _autoScroll = true; + + List _logs = []; + + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + SettingsManager settingsManager = + Provider.of(context, listen: false); + + Size size = MediaQuery.of(context).size; + + Map arguments = + ModalRoute.of(context)?.settings.arguments as Map; + DockerContainer container = arguments['container']; + Endpoint endpoint = arguments['endpoint']; + + if (_onLoad && mounted) { + _onLoad = false; + _getDetailedData(endpoint, container, user); + } + + if (settingsManager.autoRefresh && !_refreshing) { + _refreshing = true; + Future.delayed(Duration(seconds: settingsManager.autoRefreshInterval), + () { + if (mounted) { + _refreshing = false; + _getDetailedData(endpoint, container, user); + + if (_showLogs && mounted) { + _getLogs(endpoint, container, user); + } + } + }); + } + + if (detailedContainer == null) { + return Scaffold( + body: CustomScrollView( + shrinkWrap: true, + controller: _scrollController, + slivers: const [ + PortariusAppBar(), + SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + ], + ), + ); + } + + detailedContainer?.mounts + ?.sort((a, b) => a.destination!.compareTo(b.destination!)); + + return Scaffold( + floatingActionButton: SpeedDial( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + spaceBetweenChildren: 2.5, + spacing: 5, + children: [ + /*SpeedDialChild( + child: detailedContainer!.state!.status == 'running' + ? const Icon(Icons.stop_circle_outlined) + : const Icon(Icons.play_circle_outlined), + label: detailedContainer!.state!.status == 'running' + ? 'Stop' + : 'Start', + onTap: () { + + }, + ),*/ + SpeedDialChild( + child: const Icon(Icons.data_array), + label: 'Logs', + onTap: () { + setState(() { + _showLogs = !_showLogs; + if (_showLogs) { + // scroll to the bottom of page + Future.delayed(const Duration(milliseconds: 50), () { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 500), + curve: Curves.easeOut); + }); + } + }); + }, + ), + /*SpeedDialChild( + child: const Icon(Icons.delete), + label: 'Delete', + onTap: () {}, + ),*/ + ], + child: const Icon(Icons.menu), + ), + body: CustomScrollView( + shrinkWrap: true, + controller: _scrollController, + slivers: [ + PortariusAppBar( + title: detailedContainer!.name ?? 'Unknown', + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Wrap( + clipBehavior: Clip.antiAlias, + alignment: WrapAlignment.start, + spacing: 10, + children: [ + DockerStatusCard(detailedContainer: detailedContainer!), + if (detailedContainer!.mounts!.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Text('Volumes', + style: Theme.of(context).textTheme.headline6), + ...detailedContainer!.mounts!.map((mount) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + Text( + '${mount.source} -> ${mount.destination}', + ), + ], + ); + }), + ], + ), + ), + ), + if (_showLogs) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 10, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text('Logs', + style: Theme.of(context) + .textTheme + .headline6), + Row( + children: [ + const Text('Auto scroll'), + Switch( + value: _autoScroll, + onChanged: (value) { + setState(() { + _autoScroll = value; + }); + }, + ), + ], + ), + ], + ), + ), + const Divider(), + SizedBox( + height: size.height * 0.65, + child: _logs.isEmpty + ? const Center( + child: CircularProgressIndicator()) + : ListView( + controller: _logScrollController, + children: [ + ..._logs.map((line) { + return InkWell( + customBorder: + RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(18), + ), + onLongPress: () { + Clipboard.setData( + ClipboardData(text: line)); + ToastContext().init(context); + Toast.show( + 'Copied line to clipboard', + duration: Toast.lengthLong, + gravity: Toast.bottom); + }, + child: Padding( + padding: const EdgeInsets.only( + left: 10, + right: 10, + ), + child: Column( + children: [ + SizedBox( + height: 15, + ), + Text(line), + const Divider(), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ) + ], + ), + ); + } + + _getDetailedData( + Endpoint endpoint, DockerContainer container, User user) async { + DetailedDockerContainer? detailedContainer = + await RemoteService().getDockerContainer(user, endpoint, container.id); + if (detailedContainer != null && mounted) { + setState(() { + this.detailedContainer = detailedContainer; + }); + } + } + + void _getLogs(Endpoint endpoint, DockerContainer container, User user) async { + List logs = + await RemoteService().getContainerLogs(user, endpoint, container.id); + + // merged list + List mergedLogs = []; + mergedLogs.addAll(_logs); + + print('logs: ${mergedLogs.length}'); + + // find last line in old logs + String lastLine = ''; + + if (_logs.isNotEmpty) { + lastLine = _logs.last; + } + + if (lastLine != '') { + for (String line in logs) { + if (line.compareTo(lastLine) > 0) { + mergedLogs.add(line); + break; + } + } + } else { + mergedLogs.addAll(logs); + } + + print('logs: ${mergedLogs.length}'); + + setState(() { + _logs = mergedLogs; + Future.delayed(const Duration(milliseconds: 10), () { + if (_showLogs && + _autoScroll && + _logScrollController.positions.isNotEmpty && + mounted) { + _logScrollController.animateTo( + _logScrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 200), + curve: Curves.easeIn); + } + }); + }); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart new file mode 100644 index 0000000..3cf4648 --- /dev/null +++ b/lib/pages/home/home.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:portarius/components/appbar/appbar.dart'; +import 'package:portarius/components/drawer/drawer.dart'; +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:portarius/utils/style.dart'; +import 'package:provider/provider.dart'; + +import '../../components/lists/container_grid_list.dart'; +import '../../models/portainer/endpoint.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + List _endpoints = []; + Endpoint? _selectedEndpoint; + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + super.dispose(); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + StorageManager storage = Provider.of(context, listen: true); + SettingsManager settings = + Provider.of(context, listen: true); + // ignore: unused_local_variable + StyleManager style = Provider.of(context, listen: true); + Size size = MediaQuery.of(context).size; + + /// Get the endpoints from the API and store them in the [_endpoints] list. + /// Then select the first endpoint in the list as the selected endpoint. + if (_selectedEndpoint == null || _endpoints.isEmpty) { + RemoteService().getEndpoints(user).then((endpoints) { + if (mounted) { + setState(() { + /// Select the first endpoint. + if (endpoints.isNotEmpty) { + if (settings.selectedEndpointId == null) { + _selectedEndpoint = endpoints.first; + settings.selectedEndpointId = endpoints.first.id; + storage.saveEndpointId(endpoints.first.id ?? 1); + } else { + _selectedEndpoint = endpoints.firstWhere( + (endpoint) => endpoint.id == settings.selectedEndpointId, + orElse: () => endpoints.first, + ); + } + } + _endpoints = endpoints; + }); + } + }); + } + + return WillPopScope( + onWillPop: () async { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + + if (_scrollController.offset == 0) { + bool result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Are you sure?'), + content: const Text('Do you want to exit the application?'), + actions: [ + TextButton( + child: const Text('No'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: const Text('Yes'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ), + ); + if (result) { + SystemNavigator.pop(); + } + } else { + return false; + } + } + return false; + }, + child: Scaffold( + drawer: const Padding( + padding: EdgeInsets.only( + top: 45, + bottom: 10, + ), + child: PortariusDrawer( + pageRoute: '/home', + )), + body: RefreshIndicator( + onRefresh: () async { + _endpoints = await RemoteService().getEndpoints(user); + setState(() { + _selectedEndpoint = null; + }); + }, + edgeOffset: size.height * .25, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + PortariusAppBar(endpoint: _selectedEndpoint), + _selectedEndpoint == null + ? const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ) + : SliverPadding( + padding: EdgeInsets.only( + bottom: size.height * 0.05, + ), + sliver: ContainerGrid( + endpoint: _selectedEndpoint!, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/loading/loading.dart b/lib/pages/loading/loading.dart new file mode 100644 index 0000000..173cb6b --- /dev/null +++ b/lib/pages/loading/loading.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:provider/provider.dart'; + +import '../../utils/settings.dart'; + +class LoadingPage extends StatefulWidget { + const LoadingPage({Key? key}) : super(key: key); + + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + bool _timeout = false; + @override + Widget build(BuildContext context) { + Size size = MediaQuery.of(context).size; + StorageManager storage = Provider.of(context, listen: true); + SettingsManager settings = + Provider.of(context, listen: true); + + if (!_timeout) { + Future.delayed(const Duration(seconds: 10), () { + if (mounted) { + setState(() { + _timeout = true; + }); + } + }); + } + + return Scaffold( + body: Center( + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + flex: 45, + child: Container( + margin: EdgeInsets.only(top: size.height * 0.05), + child: + Image.asset('assets/icons/icon.png', fit: BoxFit.fill)), + ), + Flexible( + flex: 15, + child: Container(), + ), + Flexible( + flex: 40, + child: _timeout + ? Center( + child: TextButton( + onPressed: () async { + setState(() { + _timeout = false; + }); + await storage.init(context); + await settings.init(storage); + }, + child: const Text( + 'Try Again', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + : Column( + children: const [ + CircularProgressIndicator(), + SizedBox(height: 12.5), + Text('Loading...'), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart new file mode 100644 index 0000000..b0df2f6 --- /dev/null +++ b/lib/pages/settings/settings.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/components/cards/about_tile.dart'; +import 'package:portarius/services/local_auth.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:provider/provider.dart'; + +import '../../components/appbar/appbar.dart'; +import '../../components/drawer/drawer.dart'; +import '../../models/portainer/user.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({Key? key}) : super(key: key); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + super.dispose(); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + SettingsManager settings = + Provider.of(context, listen: true); + StorageManager storage = Provider.of(context, listen: true); + User user = Provider.of(context, listen: true); + + return WillPopScope( + onWillPop: () async { + Navigator.of(context).pushReplacementNamed( + '/home', + ); + return true; + }, + child: Scaffold( + drawer: const Padding( + padding: EdgeInsets.only( + top: 45, + bottom: 10, + ), + child: PortariusDrawer( + pageRoute: '/settings', + )), + body: CustomScrollView( + controller: _scrollController, + shrinkWrap: true, + slivers: [ + const PortariusAppBar(), + SliverToBoxAdapter( + child: Column( + children: [ + Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 15, + top: 20, + ), + child: Text( + 'Page Refresh', + style: Theme.of(context).textTheme.headline4, + ), + ), + ), + Card( + margin: + const EdgeInsets.only(left: 15, right: 15, top: 20), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: ListTile( + title: const Text('Auto Refresh'), + subtitle: const Text('Toggles the auto refresh.'), + trailing: Switch( + value: settings.autoRefresh, + onChanged: (value) async { + await storage.saveAutoRefresh(value); + await settings.refreshSettings(storage); + }, + ), + ), + ), + ), + Card( + margin: + const EdgeInsets.only(left: 15, right: 15, top: 10), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: ListTile( + enabled: settings.autoRefresh, + title: const Text('Auto Refresh Interval'), + subtitle: const Text( + 'Sets the auto refresh interval in seconds.'), + trailing: DropdownButton( + value: settings.autoRefreshInterval, + items: [ + ...[1, 3, 5, 10, 15, 30] + .map( + (interval) => DropdownMenuItem( + value: interval, + child: Text( + '${interval}s', + style: const TextStyle( + fontSize: 16, + ), + ), + ), + ) + .toList(), + ], + onChanged: !settings.autoRefresh + ? null + : (int? value) { + storage + .saveAutoRefreshInterval(value ?? 10); + settings.refreshSettings(storage); + }, + ), + ), + ), + ), + ], + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 15, + top: 20, + ), + child: Text( + 'App Lock', + style: Theme.of(context).textTheme.headline4, + ), + ), + ), + Card( + margin: const EdgeInsets.only(left: 15, right: 15, top: 20), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: ListTile( + title: const Text('Use biometric authentication'), + subtitle: + const Text('Toggles biometric authentication.'), + trailing: Switch( + value: settings.biometricEnabled, + onChanged: (value) async { + if (await LocalAuthManager.deviceSupported()) { + await storage.saveBiometric(value); + await settings.refreshSettings(storage); + } else { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text( + 'Biometric authentication'), + content: const Text( + 'Your device does not support biometric authentication.' + '\n\nThis may be because you have not enabled it in your device settings.'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }); + } + }, + ), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 15, + top: 20, + ), + child: Text( + 'About', + style: Theme.of(context).textTheme.headline4, + ), + ), + ), + const PortariusAboutTile(), + const SizedBox( + height: 20, + ), + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/users/user_managment.dart b/lib/pages/users/user_managment.dart new file mode 100644 index 0000000..7a557db --- /dev/null +++ b/lib/pages/users/user_managment.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/components/cards/setting_tile.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:provider/provider.dart'; +import 'package:toast/toast.dart'; + +import '../../components/appbar/appbar.dart'; +import '../../components/drawer/drawer.dart'; +import '../../models/portainer/token.dart'; +import '../../models/portainer/user.dart'; + +class UserManagerPage extends StatefulWidget { + const UserManagerPage({ + Key? key, + }) : super(key: key); + + @override + State createState() => _UserManagerPageState(); +} + +class _UserManagerPageState extends State { + final ScrollController _scrollController = ScrollController(); + bool _areUsersLoaded = false; + + @override + void dispose() { + // TODO: implement dispose + super.dispose(); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + StorageManager storage = Provider.of(context, listen: true); + User user = Provider.of(context, listen: true); + + List userList = storage.savedUsers; + bool? fromAuthPage = + ModalRoute.of(context)?.settings.arguments as bool? ?? false; + + if (!_areUsersLoaded) { + storage.refreshUsers().then((value) => setState(() { + _areUsersLoaded = true; + })); + } + + return WillPopScope( + onWillPop: () async { + if (fromAuthPage) { + Navigator.of(context).pushReplacementNamed('/'); + } else { + Navigator.of(context).pushReplacementNamed('/home'); + } + return false; + }, + child: Scaffold( + floatingActionButton: FloatingActionButton( + tooltip: 'Add User', + onPressed: () async { + await _showAddUserDialog(storage); + }, + child: const Icon(Icons.add), + ), + drawer: fromAuthPage + ? null + : const Padding( + padding: EdgeInsets.only( + top: 45, + bottom: 10, + ), + child: PortariusDrawer( + pageRoute: '/users', + )), + body: CustomScrollView( + controller: _scrollController, + shrinkWrap: true, + slivers: [ + const PortariusAppBar(), + SliverToBoxAdapter( + child: Column( + children: [ + Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + top: 20, + ), + child: Text( + 'Users', + style: Theme.of(context).textTheme.headline4, + ), + ), + ), + ListBody( + children: [ + ...userList.map((u) { + return Padding( + padding: const EdgeInsets.all(0.0), + child: PortariusSettingTile( + enabled: fromAuthPage ? true : user != u, + title: u.username, + subtitle: u.hostUrl, + trailing: user != u + ? const Icon(Icons.person) + : const Icon( + Icons.check, + size: 30, + ), + onTap: () async { + await _showUserDialog( + user, u, storage, fromAuthPage); + }, + ), + ); + }), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + _validateUser(String username, String hostUrl, String password, + StorageManager storage) async { + Token? token = + await RemoteService().authPortainer(username, password, hostUrl); + + if (token == null && mounted) { + ToastContext().init(context); + Toast.show( + 'Invalid credentials.', + ); + return null; + } + + if (hostUrl.endsWith('/')) { + hostUrl = hostUrl.substring(0, hostUrl.length - 1); + } + + if (token != null) { + User user = User( + username: username, + hostUrl: hostUrl, + password: password, + token: token, + ); + await storage.addUserToList(user); + if (mounted) { + Navigator.pop(context); + } + } + } + + _showUserDialog(User loggedUser, User clickedUser, StorageManager storage, + bool fromAuthPage) async { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(clickedUser.username), + content: Text( + 'Viewing user\n${clickedUser.username}@${clickedUser.hostUrl}', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: const Text('Select'), + onPressed: () async { + loggedUser.setNewUser(clickedUser); + await storage.saveUser(clickedUser); + if (mounted) { + storage.initUser(context); + if (fromAuthPage) { + Navigator.pushNamedAndRemoveUntil( + context, '/auth', (route) => false); + } else { + Navigator.pop(context); + } + } + }, + ), + TextButton( + child: const Text('Delete'), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete User'), + content: const Text( + 'Are you sure you want to delete this user?', + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: const Text('Delete'), + onPressed: () async { + await storage.removeUserFromList(clickedUser); + if (mounted) { + Navigator.pop(context); + Navigator.pop(context); + } + }, + ), + ], + ); + }, + ); + }, + ), + ], + ); + }, + ); + } + + _showAddUserDialog(StorageManager storage) { + TextEditingController usernameController = TextEditingController(); + TextEditingController passwordController = TextEditingController(); + TextEditingController hostUrlController = TextEditingController(); + + Size size = MediaQuery.of(context).size; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Add User'), + content: Form( + child: Wrap( + children: [ + TextFormField( + controller: hostUrlController, + scrollPadding: EdgeInsets.all(size.height * .15), + textInputAction: TextInputAction.next, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Host URL', + hintText: 'https://portainer.example.com', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a host URL'; + } + return null; + }, + ), + SizedBox( + height: 10, + width: size.width, + ), + TextFormField( + controller: usernameController, + scrollPadding: EdgeInsets.all(size.height * .1), + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'admin', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username'; + } + return null; + }, + ), + SizedBox( + height: 10, + width: size.width, + ), + TextFormField( + controller: passwordController, + scrollPadding: EdgeInsets.all(size.height * .12), + obscureText: true, + textInputAction: TextInputAction.done, + onEditingComplete: () { + _validateUser( + usernameController.text, + hostUrlController.text, + passwordController.text, + storage); + }, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + return null; + }, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Add'), + onPressed: () { + _validateUser(usernameController.text, hostUrlController.text, + passwordController.text, storage); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/pages/wrapper.dart b/lib/pages/wrapper.dart new file mode 100644 index 0000000..61d7e4a --- /dev/null +++ b/lib/pages/wrapper.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:portarius/models/portainer/user.dart'; +import 'package:portarius/pages/loading/loading.dart'; +import 'package:portarius/services/local_auth.dart'; +import 'package:portarius/services/storage.dart'; +import 'package:portarius/utils/settings.dart'; +import 'package:provider/provider.dart'; +import 'auth/authpage.dart'; +import 'home/home.dart'; + +/// [Wrapper] for switching between [AuthPage] and [HomePage]. +/// This is the main entry point of the app. +/// It uses [Provider] to provide the [User] model to the [HomePage]. +/// The [User] model is used to store the username, password and host URL of the user. +/// [Token] is also stored in this model, but can be null. +/// The token is used to access the Portainer API. +/// With no token, the user is redirected to the [AuthPage]. +/// With a token, the user is redirected to the [HomePage]. +class Wrapper extends StatefulWidget { + const Wrapper({Key? key}) : super(key: key); + + @override + State createState() => _WrapperState(); +} + +class _WrapperState extends State { + bool isAuthing = false; + @override + Widget build(BuildContext context) { + User user = Provider.of(context, listen: true); + StorageManager storage = Provider.of(context, listen: true); + SettingsManager settings = + Provider.of(context, listen: true); + bool isAuthenticated = settings.isAuthenticated; + + if (!storage.isInitialized && !settings.isInitialized) { + return const LoadingPage(); + } + + print('Biometrics: ${settings.biometricEnabled}'); + print('Authenticated: ${isAuthenticated}'); + print('User: ${user.token?.jwt}'); + + if (settings.biometricEnabled && !isAuthenticated) { + if (mounted && !isAuthing) { + _runBiometrics(settings); + } + return const LoadingPage(); + } + + if (user.token != null && settings.biometricEnabled && isAuthenticated) { + return const HomePage(); + } + + if (user.token != null && !settings.biometricEnabled) { + return const HomePage(); + } + + return const AuthPage(); + } + + _runBiometrics(SettingsManager settings) async { + isAuthing = true; + final result = await LocalAuthManager.authenticate(); + setState(() { + settings.isAuthenticated = result; + isAuthing = false; + }); + } +} diff --git a/lib/services/local_auth.dart b/lib/services/local_auth.dart new file mode 100644 index 0000000..b56fd07 --- /dev/null +++ b/lib/services/local_auth.dart @@ -0,0 +1,46 @@ +import 'package:local_auth/local_auth.dart'; +import 'package:local_auth_android/local_auth_android.dart'; +import 'package:local_auth_ios/local_auth_ios.dart'; + +class LocalAuthManager { + static final LocalAuthentication _instance = LocalAuthentication(); + + static Future hasBiometrics() { + return _instance.canCheckBiometrics; + } + + static Future deviceSupported() async { + try { + return await _instance.isDeviceSupported(); + } catch (e) { + return false; + } + } + + static Future authenticate() async { + final isAvailable = await hasBiometrics(); + if (!isAvailable) { + return false; + } + + try { + return await _instance.authenticate( + localizedReason: 'Authenticate to access Portarius', + options: const AuthenticationOptions( + stickyAuth: true, + useErrorDialogs: true, + ), + authMessages: [ + const AndroidAuthMessages( + signInTitle: 'Oops! Biometric authentication required!', + cancelButton: 'No thanks', + ), + const IOSAuthMessages( + cancelButton: 'No thanks', + ), + ]); + } catch (e) { + return false; + } + } +} diff --git a/lib/services/remote.dart b/lib/services/remote.dart new file mode 100644 index 0000000..d3ae896 --- /dev/null +++ b/lib/services/remote.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:portarius/models/docker/detailed_container.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; + +import '../models/docker/docker_container.dart'; +import '../models/portainer/token.dart'; +import '../models/portainer/user.dart'; + +class RemoteService { + /// Authenticates the user with the given credentials and returns a [Token]. + /// The [Token] will be used to access the Portainer API. + Future authPortainer( + String username, String password, String hostUrl) async { + http.Client client = http.Client(); + + // * If there is a '/' at the end of the hostUrl, remove it. + if (hostUrl.endsWith('/')) { + hostUrl = hostUrl.substring(0, hostUrl.length - 1); + } + + Uri uri = Uri.parse("$hostUrl/api/auth"); + + http.Response response = await client.post( + uri, + body: jsonEncode({ + "username": username, + "password": password, + }), + headers: { + "Content-Type": "application/json", + "Charset": "utf-8", + }, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + Token token = tokenFromJson(response.body); + Box box = await Hive.openBox('portainer'); + box.put('jwt', token.jwt); + + return token; + } else { + debugPrint('authPortainer: ${response.statusCode}'); + return null; + } + } + + /// Send logout request to portainer API. + /// Function will return true or false depending on if the request was successful + Future logoutPortainer(User user) async { + http.Client client = http.Client(); + + // * If there is a '/' at the end of the hostUrl, remove it. + if (user.hostUrl.endsWith('/')) { + user.hostUrl = user.hostUrl.substring(0, user.hostUrl.length - 1); + } + + Uri uri = Uri.parse("${user.hostUrl}/api/auth/logout"); + + http.Response response = await client.post( + uri, + headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }, + ); + + if (response.statusCode == 204) { + return true; + } else { + debugPrint('logoutPortainer: ${response.statusCode}'); + return false; + } + } + + /// Check if currently stored [Token] is valid. + /// If the token is valid, it will return true. + /// If the token is not valid, it will return false. + /// If there is no token stored, it will return false. + /// If the token is stored, but it is not valid, it will remove the token from the local storage. + Future isTokenValid(User user) async { + if (user.token == null) { + return false; + } + + http.Client client = http.Client(); + Uri uri = Uri.parse("${user.hostUrl}/api/motd"); + http.Response response = await client.get( + uri, + headers: {"Authorization": user.token?.getBearerToken() ?? ''}, + ); + + if (response.statusCode == 200) { + return true; + } else { + debugPrint('isTokenValid: ${response.statusCode}'); + return false; + } + } + + /// Returns a [List] containing all containers. + /// The [Token] will be used to access the Portainer API. + Future> getDockerContainerList( + User user, Endpoint endpoint) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/json?all=true", + ); + + http.Response response = await client.get(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 200) { + return dockerContainerFromJson(response.body); + } else { + debugPrint('getDockerContainerList: ${response.statusCode}'); + throw Exception('Failed to load post'); + } + } + + /// Get a [DockerContainer] by its id. + /// The [Token] will be used to access the Portainer API. + /// The [DockerContainer] will be returned. + /// If the container does not exist, it will return null. + Future getDockerContainer( + User user, Endpoint endpoint, String containerId) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/$containerId/json"); + + http.Response response = await client.get(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 200) { + return detailedDockerContainerFromJson(response.body); + } else { + debugPrint('getDockerContainer: ${response.statusCode}'); + throw Exception('Failed to load post'); + } + } + + /// Restart a [DockerContainer] with the given id. + /// The [Token] will be used to access the Portainer API. + /// Returns true if the request was successful. + /// Returns false if the request was not successful. + Future restartDockerContainer( + User user, Endpoint endpoint, String containerId) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/$containerId/restart"); + + http.Response response = await client.post(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 204) { + return true; + } else { + debugPrint('restartDockerContainer: ${response.statusCode}'); + return false; + } + } + + /// Start a [DockerContainer] with the given id. + /// The [Token] will be used to access the Portainer API. + /// Returns true if the request was successful. + /// Returns false if the request was not successful. + Future startDockerContainer( + User user, Endpoint endpoint, String containerId) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/$containerId/start"); + + http.Response response = await client.post(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 204) { + return true; + } else { + debugPrint('startDockerContainer: ${response.statusCode}'); + return false; + } + } + + /// Stop a [DockerContainer] with the given id. + /// The [Token] will be used to access the Portainer API. + /// Returns true if the request was successful. + /// Returns false if the request was not successful. + Future stopDockerContainer( + User user, Endpoint endpoint, String containerId) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/$containerId/stop"); + + http.Response response = await client.post(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 204) { + return true; + } else { + debugPrint('stopDockerContainer: ${response.statusCode}'); + return false; + } + } + + /// Get stacks from portainer API. + /// The [Token] will be used to access the Portainer API. + Future getStacks(User user) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse("${user.hostUrl}/api/stacks"); + + http.Response response = await client.get( + uri, + headers: {"Authorization": user.token?.getBearerToken() ?? ''}, + ); + + if (response.statusCode == 200) { + return response.body; + } else { + debugPrint('getStacks: ${response.statusCode}'); + throw Exception('Failed to load post'); + } + } + + /// Get endpoints from portainer API. + Future> getEndpoints(User user) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse("${user.hostUrl}/api/endpoints"); + + http.Response response = await client.get( + uri, + headers: {"Authorization": user.token?.getBearerToken() ?? ''}, + ); + + if (response.statusCode == 200) { + return List.from( + json.decode(response.body).map((x) => Endpoint.fromJson(x))); + } else { + debugPrint('getEndpoints: ${response.statusCode}'); + throw Exception('Failed to load post'); + } + } + + Future> getContainerLogs( + User user, Endpoint endpoint, String containerId) async { + http.Client client = http.Client(); + + Uri uri = Uri.parse( + "${user.hostUrl}/api/endpoints/${endpoint.id}/docker/containers/$containerId/logs?tail=100&stdout=true&stderr=true"); + + http.Response response = await client.get(uri, headers: { + "Authorization": user.token?.getBearerToken() ?? '', + }); + + if (response.statusCode == 200) { + String splitStr = response.body.replaceRange(7, response.body.length, ''); + + List returnList = response.body.split(splitStr); + returnList.removeAt(0); + for (int i = 0; i < returnList.length; i++) { + returnList[i] = returnList[i].replaceRange(0, 1, ''); + } + + return returnList; + } else { + debugPrint('getLogs: ${response.statusCode}'); + debugPrint('getLogs: ${response.body}'); + throw Exception('Failed to load post'); + } + } +} diff --git a/lib/services/storage.dart b/lib/services/storage.dart new file mode 100644 index 0000000..e69d5c2 --- /dev/null +++ b/lib/services/storage.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:portarius/services/remote.dart'; +import 'package:provider/provider.dart'; + +import '../models/portainer/token.dart'; +import '../models/portainer/user.dart'; + +class StorageManager extends ChangeNotifier { + /// Encryption key used to encrypt and decrypt the data. + final String encryptionKey; + + /// Stores the token for the API calls, username, password and host URL. + late Box? _storageBox; + bool isInitialized = false; + + /// PackkageInfo + late PackageInfo _packageInfo; + get packageInfo => _packageInfo; + + /// Saved user list + List _savedUsers = []; + get savedUsers => _savedUsers; + + StorageManager(this.encryptionKey) : super(); + + /// Initializes the [StorageManager]. + /// This method is called when the app is started. + /// It opens the local storage. + /// If the storage is empty, it will create a new one. + Future init(BuildContext context) async { + _storageBox = await Hive.openBox('portarius', + encryptionCipher: HiveAesCipher(base64Decode(encryptionKey))); + _packageInfo = await PackageInfo.fromPlatform(); + + if (_savedUsers.isEmpty) { + List tempUserStorage = + await _storageBox!.get('savedUsers', defaultValue: []); + _savedUsers = tempUserStorage + .map((user) => User( + username: user.username, + hostUrl: user.hostUrl, + password: user.password, + token: user.token)) + .toList(); + } + + // ignore: use_build_context_synchronously + await initUser(context); + + isInitialized = true; + notifyListeners(); + } + + /// Load user data + /// This method is called when the user is authenticated. + /// It loads the user data from the local storage. + /// It will also check if the token is valid. + /// If the token is not valid, it will try to auth the user again. + /// If the token is valid, it will return the user. + /// If the token is null, it will return null. + Future initUser(BuildContext context) async { + User providedUser = Provider.of(context, listen: false); + User? user = await _storageBox!.get('user'); + + if (user == null || user.hostUrl.isEmpty) { + return; + } + + if (!(await RemoteService().isTokenValid(user)) && + user.password.isNotEmpty && + user.username.isNotEmpty) { + Token? token = await RemoteService().authPortainer( + user.username, + user.password, + user.hostUrl, + ); + + if (token == null) { + return; + } + + if (!_savedUsers.contains(user)) { + _savedUsers.add(user); + } + user.setToken(token); + notifyListeners(); + } + + if (!_savedUsers.contains(user)) { + _savedUsers.add(user); + } + providedUser.setNewUser(user); + saveUser(user); + } + + /// Save user data + Future saveUser(User user) async { + /// If hostUrl ends with '/', remove it. + if (user.hostUrl.endsWith('/')) { + user.hostUrl = user.hostUrl.substring(0, user.hostUrl.length - 1); + } + + await _storageBox!.put('user', user); + } + + /// Clear user data + Future clearUser() async { + await _storageBox!.delete('user'); + } + + /// Save selected endpoint id + Future saveEndpointId(int id) async { + await _storageBox!.put('endpoint', id); + notifyListeners(); + } + + /// Load selected endpoint id + Future loadEndpointId() async { + return await _storageBox!.get('endpoint'); + } + + /// Clear selected endpoint id + Future clearEndpointId() async { + await _storageBox!.delete('endpoint'); + notifyListeners(); + } + + /// Save auto-refresh data + Future saveAutoRefresh(bool autoRefresh) async { + await _storageBox!.put('autoRefresh', autoRefresh); + notifyListeners(); + } + + /// Load auto-refresh data + Future loadAutoRefresh() async { + return await _storageBox!.get('autoRefresh') ?? true; + } + + /// Load auto-refresh interval data + Future loadAutoRefreshInterval() async { + return await _storageBox!.get('autoRefreshInterval') ?? 10; + } + + /// Save auto-refresh interval data + Future saveAutoRefreshInterval(int interval) async { + await _storageBox!.put('autoRefreshInterval', interval); + notifyListeners(); + } + + /// Load user list + Future loadUsers() async { + List tempUserStorage = + await _storageBox!.get('savedUsers', defaultValue: []); + _savedUsers = tempUserStorage + .map((user) => User( + username: user.username, + hostUrl: user.hostUrl, + password: user.password, + token: user.token)) + .toList(); + } + + /// Save user list + Future saveUsers(List userList) async { + await _storageBox!.put('savedUsers', userList); + notifyListeners(); + } + + /// Add to user list + Future addUserToList(User user) async { + if (!_savedUsers.contains(user)) { + _savedUsers.add(user); + } + + await saveUsers(_savedUsers); + } + + /// Remove from user list + Future removeUserFromList(User user) async { + _savedUsers.removeWhere((element) => + element.username == user.username && element.hostUrl == user.hostUrl); + await saveUsers(_savedUsers); + } + + /// Replace user in user list with new user + Future replaceUserInList(User oldUser, User newUser) async { + _savedUsers.removeWhere((element) => + element.username == oldUser.username && + element.hostUrl == oldUser.hostUrl); + _savedUsers.add(newUser); + await saveUsers(_savedUsers); + } + + /// Clear user list + Future clearUsers() async { + await _storageBox!.delete('savedUsers'); + notifyListeners(); + } + + /// Refresh user list + Future refreshUsers() async { + _savedUsers = []; + await loadUsers(); + notifyListeners(); + } + + /// Load biometric data + Future loadBiometric() async { + return await _storageBox!.get('biometric', defaultValue: false); + } + + /// Save biometric data + Future saveBiometric(bool biometric) async { + await _storageBox!.put('biometric', biometric); + notifyListeners(); + } + + /// Returns the [Box] + Box? get storageBox => _storageBox; +} diff --git a/lib/utils/settings.dart b/lib/utils/settings.dart new file mode 100644 index 0000000..c49f5c0 --- /dev/null +++ b/lib/utils/settings.dart @@ -0,0 +1,59 @@ +import 'package:flutter/cupertino.dart'; +import 'package:portarius/models/portainer/endpoint.dart'; +import 'package:portarius/services/storage.dart'; + +class SettingsManager extends ChangeNotifier { + /// This is the [Endpoint] that is used for the Portainer API. + /// If the [Endpoint] is null, it will be set to the first endpoint in the list. + int? _selectedEndpointId; + int? get selectedEndpointId => _selectedEndpointId; + + late bool _autoRefresh; + bool get autoRefresh => _autoRefresh; + + late int _autoRefreshInterval; + int get autoRefreshInterval => _autoRefreshInterval; + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + bool _biometricEnabled = false; + bool get biometricEnabled => _biometricEnabled; + + bool _isAuthenticated = false; + bool get isAuthenticated => _isAuthenticated; + set isAuthenticated(bool value) { + _isAuthenticated = value; + notifyListeners(); + } + + /// This is the [Endpoint] that is used for the Portainer API. + /// Get the selected [Endpoint] + + /// This is the [Endpoint] that is used for the Portainer API. + /// Set the [Endpoint] + set selectedEndpointId(int? value) { + _selectedEndpointId = value; + notifyListeners(); + } + + /// Init the [SettingsManager] before running this. + /// This will load the [Endpoint] from the [StorageManager]. + Future init(StorageManager storage) async { + _selectedEndpointId = await storage.loadEndpointId(); + _autoRefresh = await storage.loadAutoRefresh(); + _autoRefreshInterval = await storage.loadAutoRefreshInterval(); + _biometricEnabled = await storage.loadBiometric(); + + _isInitialized = true; + notifyListeners(); + } + + Future refreshSettings(StorageManager storage) async { + _selectedEndpointId = await storage.loadEndpointId(); + _autoRefresh = await storage.loadAutoRefresh(); + _autoRefreshInterval = await storage.loadAutoRefreshInterval(); + _biometricEnabled = await storage.loadBiometric(); + notifyListeners(); + } +} diff --git a/lib/utils/style.dart b/lib/utils/style.dart new file mode 100644 index 0000000..d729676 --- /dev/null +++ b/lib/utils/style.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +class StyleManager extends ChangeNotifier { + /// Change theme in the local storage. + Future setTheme(ThemeMode theme) async { + var box = await Hive.openBox('style'); + await box.put('theme', theme == ThemeMode.dark ? 'dark' : 'light'); + notifyListeners(); + } + + Future getTheme() async { + var box = await Hive.openBox('style'); + + String? themeString = await box.get('theme'); + + if (themeString == null) { + return null; + } + + return themeString == 'light' ? ThemeMode.light : ThemeMode.dark; + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..cb6ea7e --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,831 @@ +# 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: "41.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.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" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.2" + 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.1.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 main" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + 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.4.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.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.0" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + flex_color_scheme: + dependency: "direct main" + description: + name: flex_color_scheme + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.3" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + flutter_speed_dial: + dependency: "direct main" + description: + name: flutter_speed_dial + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + hive: + dependency: "direct main" + description: + name: hive + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + hive_generator: + dependency: "direct main" + description: + name: hive_generator + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.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.6.0" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" + local_auth_ios: + dependency: transitive + description: + name: local_auth_ios + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.7" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + 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.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.2" + 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: "1.0.5" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.16" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" + 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.0.7" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.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" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" + provider: + dependency: "direct main" + 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" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.2" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + 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.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.9" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + toast: + dependency: "direct main" + description: + name: toast + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.5" + 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.1.0" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.12" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" + 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.17.1 <3.0.0" + flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9760597 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,90 @@ +name: portarius +description: Portainer companion app + +# 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.0 + +environment: + sdk: ">=2.17.1 <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: + build_runner: ^2.2.0 + flex_color_scheme: ^5.1.0 + flutter: + sdk: flutter + flutter_launcher_icons: ^0.9.3 + flutter_secure_storage: ^5.0.2 + flutter_speed_dial: ^6.0.0 + hive: ^2.2.2 + hive_generator: ^1.1.3 + http: ^0.13.4 + local_auth: ^2.1.0 + package_info_plus: ^1.4.2 + path_provider: ^2.0.11 + provider: ^6.0.3 + toast: ^0.3.0 + url_launcher: ^6.1.5 + +dev_dependencies: + flutter_lints: ^2.0.0 + flutter_test: + sdk: flutter + +flutter_icons: + android: true + ios: true + image_path: "assets/icons/icon.png" + +# 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 packages. +flutter: + # 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 + # To add assets to your application, add an assets section, like this: + assets: + - "assets/icons/icon.png" + # 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