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